skills: парсер YAML-скилов (opencode-формат)

Новый KMP-модуль :skills (9 целей, зеркалит :proto). Парсит скилы
в формате opencode: YAML-фронтматтер с name+description, затем markdown
body.

  - SkillFile(name, description, body) — @Serializable результат.
  - SkillParseError — sealed-иерархия ошибок: Empty, MissingOpeningFence,
    MissingClosingFence, InvalidYaml, SchemaMismatch, BlankName.
  - SkillParser.parse(raw): SkillParseResult + parseOrThrow(raw): SkillFile.
    Обрабатывает BOM, CRLF, имена с двоеточиями (backend:spring:db-base),
    multiline-YAML, пустое тело; неизвестные ключи фронтматтера игнорирует.
  - SkillParserTest — 16 тестов, 0 failures.

YAML-библиотека — com.charleskorn.kaml 0.104.0 (не JetBrains,
интеграция с kotlinx @Serializable). Грабли kaml 0.104: параметр
ignoreUnknownKeys переименован в strictMode (инвертирован);
missing-required-field прилетает как YamlException, не
SerializationException — различаем по сообщению.

Проверка: :skills:jvmTest 16/16, :skills:assemble (9 целей) — зелёные.
This commit is contained in:
2026-09-13 21:28:47 +03:00
parent cad4d5fc7c
commit f4caab940e
7 changed files with 387 additions and 0 deletions
+4
View File
@@ -4,6 +4,7 @@ kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
ktor = "3.1.3" ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT" a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0"
litert = "7" litert = "7"
sqldelight = "2.3.2" sqldelight = "2.3.2"
@@ -19,6 +20,9 @@ a2a-shared = { module = "pw.binom.a2a:shared", version.ref = "a2a" }
a2a-client = { module = "pw.binom.a2a:client", version.ref = "a2a" } a2a-client = { module = "pw.binom.a2a:client", version.ref = "a2a" }
a2a-server = { module = "pw.binom.a2a:server", version.ref = "a2a" } a2a-server = { module = "pw.binom.a2a:server", version.ref = "a2a" }
# --- YAML для парсинга скилов (opencode-style frontmatter). KMP. ---
kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper --- # --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" } litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
litert-openai = { module = "pw.binom.litert:litert-openai-jvm", version.ref = "litert" } litert-openai = { module = "pw.binom.litert:litert-openai-jvm", version.ref = "litert" }
+2
View File
@@ -26,6 +26,8 @@ rootProject.name = "agentik"
include(":standalone") include(":standalone")
// Собственный протокол agentik. Пока в нём пилим, потом вынесем. // Собственный протокол agentik. Пока в нём пилим, потом вынесем.
include(":proto") include(":proto")
// Парсер скилов (YAML-frontmatter + markdown body, opencode-style).
include(":skills")
// Ktor-сервер, экспонирующий Agent по HTTP (SSE + JSON). // Ktor-сервер, экспонирующий Agent по HTTP (SSE + JSON).
include(":server") include(":server")
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`. // Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
+29
View File
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
// Полный набор KMP-целей. Зеркалит :proto.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// YAML-парсинг с интеграцией в @Serializable.
implementation(libs.kaml)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
@@ -0,0 +1,32 @@
package pw.binom.agentik.skills
import kotlinx.serialization.Serializable
/**
* Скил в формате opencode (https://opencode.ai): YAML-фронтматтер с `name` +
* `description`, потом markdown body. Имя скила может содержать двоеточия
* (`backend:spring:db-base`).
*
* Пример файла:
* ```
* ---
* name: click-on
* description: Use when you need to CLICK an element...
* ---
*
* # Click-on / Type-on
* ...
* ```
*
* v1 ограничения:
* - дополнительные поля фронтматтера игнорируются (kaml в default-конфигурации
* бросает на неизвестных ключах — поэтому [SkillParser] использует
* `ignoreUnknownKeys = true`).
* - body — это сырая markdown-строка, без рендера.
*/
@Serializable
data class SkillFile(
val name: String,
val description: String,
val body: String,
)
@@ -0,0 +1,31 @@
package pw.binom.agentik.skills
/**
sealed-иерархия ошибок парсера. Каждая содержит достаточно контекста,
чтобы показать пользователю, что не так с конкретным файлом.
*/
sealed interface SkillParseError {
/** Файл пуст. */
data object Empty : SkillParseError
/** Файл не начинается с `---` (фронтматтер обязателен). */
data object MissingOpeningFence : SkillParseError {
private fun readResolve(): Any = MissingOpeningFence
}
/** Файл начинается с `---`, но закрывающего `---` нет. */
data object MissingClosingFence : SkillParseError {
private fun readResolve(): Any = MissingClosingFence
}
/** YAML во фронтматтере не распарсился. */
data class InvalidYaml(val message: String) : SkillParseError
/** YAML распарсился, но не в [SkillFile] (нет обязательного поля, тип не тот). */
data class SchemaMismatch(val message: String) : SkillParseError
/** `name` пустая или содержит только пробелы. */
data object BlankName : SkillParseError {
private fun readResolve(): Any = BlankName
}
}
@@ -0,0 +1,106 @@
package pw.binom.agentik.skills
import com.charleskorn.kaml.Yaml
import kotlinx.serialization.Serializable
/**
* Парсер скилов в формате opencode — YAML-фронтматтер + markdown body.
*
* Поведение:
* 1. Файл обязан начинаться с `---` (на отдельной строке). После — YAML до
* следующей строки `---`. Дальше — markdown body до конца файла.
* 2. Лидирующий BOM (\uFEFF) отбрасывается.
* 3. CRLF нормализуется в LF для разбора, но сохраняется как есть в body.
* 4. Дополнительные поля фронтматтера игнорируются (`ignoreUnknownKeys = true`).
* 5. `name` триммится; пустое после тримма → [SkillParseError.BlankName].
*
* Потокобезопасен: парсер без состояния, kaml тоже.
*/
object SkillParser {
private val yaml: Yaml = Yaml(
configuration = com.charleskorn.kaml.YamlConfiguration(
// 0.104: вместо `ignoreUnknownKeys` — `strictMode = false`.
// Неизвестные ключи в фронтматтере просто пропускаются.
strictMode = false,
),
)
@Serializable
private data class Frontmatter(
val name: String,
val description: String,
)
/**
* Парсит [raw] в [SkillFile]. Бросает [IllegalStateException] (с понятным
* сообщением) при любой ошибке [SkillParseError].
*/
fun parseOrThrow(raw: String): SkillFile = when (val result = parse(raw)) {
is SkillParseResult.Ok -> result.value
is SkillParseResult.Err -> throw IllegalStateException("Skill parse error: $result")
}
/**
* Парсит [raw] в [SkillFile] или [SkillParseError]. Возврат ошибки (а не
* исключения) удобен когда нужно отдать пользователю список битых скилов.
*/
fun parse(raw: String): SkillParseResult {
if (raw.isEmpty()) return SkillParseResult.Err(SkillParseError.Empty)
val text = raw.stripBom()
val lines = text.split("\n")
if (lines[0].trim() != "---") {
return SkillParseResult.Err(SkillParseError.MissingOpeningFence)
}
// Ищем закрывающий `---` (строка ровно из `---` после тримма).
val closingIdx = lines.drop(1).indexOfFirst { it.trim() == "---" }
if (closingIdx < 0) {
return SkillParseResult.Err(SkillParseError.MissingClosingFence)
}
// drop(1) сдвинул индексы на 1.
val absClosing = closingIdx + 1
val yamlText = lines.subList(1, absClosing).joinToString("\n")
val bodyText = lines.subList(absClosing + 1, lines.size).joinToString("\n")
val fm: Frontmatter = try {
yaml.decodeFromString(Frontmatter.serializer(), yamlText)
} catch (e: com.charleskorn.kaml.YamlException) {
// kaml кидает YamlException и на синтаксические ошибки YAML, и на
// отсутствие обязательных полей. Различаем по сообщению: missing-field
// ошибки kotlinx-serialization имеют форму
// "Field 'description' is required ..." → относим к SchemaMismatch.
val msg = e.message ?: "<unknown>"
val err = if ("is required" in msg || "Missing field" in msg) {
SkillParseError.SchemaMismatch(msg)
} else {
SkillParseError.InvalidYaml(msg)
}
return SkillParseResult.Err(err)
} catch (e: kotlinx.serialization.SerializationException) {
return SkillParseResult.Err(SkillParseError.SchemaMismatch(e.message ?: "<unknown>"))
}
val name = fm.name.trim()
if (name.isEmpty()) return SkillParseResult.Err(SkillParseError.BlankName)
return SkillParseResult.Ok(
SkillFile(
name = name,
description = fm.description.trim(),
body = bodyText.trimStart('\n'),
),
)
}
private fun String.stripBom(): String =
if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this
}
sealed interface SkillParseResult {
data class Ok(val value: SkillFile) : SkillParseResult
data class Err(val error: SkillParseError) : SkillParseResult
}
@@ -0,0 +1,183 @@
package pw.binom.agentik.skills
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class SkillParserTest {
private val sample = """
---
name: click-on
description: Use when you need to CLICK an element.
---
# Click-on / Type-on
Скил «руки» для UI-автоматизации.
""".trimIndent()
@Test
fun parsesOpencodeStyleSkill() {
val r = SkillParser.parse(sample)
val ok = assertIs<SkillParseResult.Ok>(r)
assertEquals("click-on", ok.value.name)
assertTrue(ok.value.description.startsWith("Use when"))
assertTrue(ok.value.body.startsWith("# Click-on"))
}
@Test
fun trimsNameAndDescription() {
val raw = """
---
name: spaced-name
description: trim me
---
body
""".trimIndent()
val r = SkillParser.parse(raw)
val ok = assertIs<SkillParseResult.Ok>(r)
assertEquals("spaced-name", ok.value.name)
assertEquals("trim me", ok.value.description)
}
@Test
fun supportsColonInName() {
val raw = """
---
name: backend:spring:db-base
description: ok
---
body
""".trimIndent()
val r = SkillParser.parse(raw)
val ok = assertIs<SkillParseResult.Ok>(r)
assertEquals("backend:spring:db-base", ok.value.name)
}
@Test
fun ignoresUnknownFrontmatterKeys() {
val raw = """
---
name: with-extras
description: ok
author: me
tags: [a, b]
---
body
""".trimIndent()
val r = SkillParser.parse(raw)
assertIs<SkillParseResult.Ok>(r)
}
@Test
fun emptyBody() {
val raw = """
---
name: a
description: b
---
""".trimIndent()
val r = SkillParser.parse(raw)
val ok = assertIs<SkillParseResult.Ok>(r)
assertEquals("", ok.value.body)
}
@Test
fun handlesBom() {
val raw = "\uFEFF---\nname: bom\ndescription: ok\n---\nbody"
val r = SkillParser.parse(raw)
val ok = assertIs<SkillParseResult.Ok>(r)
assertEquals("bom", ok.value.name)
}
@Test
fun handlesCrlf() {
val raw = "---\r\nname: crlf\r\ndescription: ok\r\n---\r\nbody"
val r = SkillParser.parse(raw)
val ok = assertIs<SkillParseResult.Ok>(r)
assertEquals("crlf", ok.value.name)
}
@Test
fun emptyInput() {
assertIs<SkillParseResult.Err>(SkillParser.parse("")).also {
assertEquals(SkillParseError.Empty, it.error)
}
}
@Test
fun missingOpeningFence() {
val raw = "name: no-fence\ndescription: ok\n---\nbody"
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
assertEquals(SkillParseError.MissingOpeningFence, it.error)
}
}
@Test
fun missingClosingFence() {
val raw = "---\nname: half-open\ndescription: ok\nbody without closing"
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
assertEquals(SkillParseError.MissingClosingFence, it.error)
}
}
@Test
fun blankNameRejected() {
val raw = "---\nname: ' '\ndescription: ok\n---\nbody"
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
assertEquals(SkillParseError.BlankName, it.error)
}
}
@Test
fun invalidYaml() {
val raw = "---\nname: [unclosed\ndescription: ok\n---\nbody"
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
assertIs<SkillParseError.InvalidYaml>(it.error)
}
}
@Test
fun schemaMismatchWhenMissingDescription() {
val raw = "---\nname: only-name\n---\nbody"
assertIs<SkillParseResult.Err>(SkillParser.parse(raw)).also {
assertIs<SkillParseError.SchemaMismatch>(it.error)
}
}
@Test
fun parseOrThrowReturnsValue() {
val skill = SkillParser.parseOrThrow(sample)
assertEquals("click-on", skill.name)
}
@Test
fun parseOrThrowRaisesOnError() {
var thrown = false
try {
SkillParser.parseOrThrow("")
} catch (_: IllegalStateException) {
thrown = true
}
assertTrue(thrown)
}
@Test
fun multiLineYamlValue() {
val raw = """
---
name: multi
description: |
line one
line two
---
body
""".trimIndent()
val r = SkillParser.parse(raw)
val ok = assertIs<SkillParseResult.Ok>(r)
assertTrue(ok.value.description.contains("line one"))
assertTrue(ok.value.description.contains("line two"))
}
}