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:
@@ -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" }
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user