diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ddd2244..3fa0d9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ kotlinx-serialization = "1.11.0" kotlinx-coroutines = "1.11.0" ktor = "3.1.3" a2a = "1.0.0-SNAPSHOT" +kaml = "0.104.0" litert = "7" 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-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-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" } litert-openai = { module = "pw.binom.litert:litert-openai-jvm", version.ref = "litert" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2245dfd..967f3ab 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,8 @@ rootProject.name = "agentik" include(":standalone") // Собственный протокол agentik. Пока в нём пилим, потом вынесем. include(":proto") +// Парсер скилов (YAML-frontmatter + markdown body, opencode-style). +include(":skills") // Ktor-сервер, экспонирующий Agent по HTTP (SSE + JSON). include(":server") // Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`. diff --git a/skills/build.gradle.kts b/skills/build.gradle.kts new file mode 100644 index 0000000..494ce26 --- /dev/null +++ b/skills/build.gradle.kts @@ -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")) + } + } +} diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillFile.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillFile.kt new file mode 100644 index 0000000..80bf750 --- /dev/null +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillFile.kt @@ -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, +) diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParseError.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParseError.kt new file mode 100644 index 0000000..1ac6d7d --- /dev/null +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParseError.kt @@ -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 + } +} diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt new file mode 100644 index 0000000..6d561fc --- /dev/null +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt @@ -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 ?: "" + 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 ?: "")) + } + + 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 +} diff --git a/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt new file mode 100644 index 0000000..b4e00e0 --- /dev/null +++ b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt @@ -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(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(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(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(r) + } + + @Test + fun emptyBody() { + val raw = """ + --- + name: a + description: b + --- + """.trimIndent() + val r = SkillParser.parse(raw) + val ok = assertIs(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(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(r) + assertEquals("crlf", ok.value.name) + } + + @Test + fun emptyInput() { + assertIs(SkillParser.parse("")).also { + assertEquals(SkillParseError.Empty, it.error) + } + } + + @Test + fun missingOpeningFence() { + val raw = "name: no-fence\ndescription: ok\n---\nbody" + assertIs(SkillParser.parse(raw)).also { + assertEquals(SkillParseError.MissingOpeningFence, it.error) + } + } + + @Test + fun missingClosingFence() { + val raw = "---\nname: half-open\ndescription: ok\nbody without closing" + assertIs(SkillParser.parse(raw)).also { + assertEquals(SkillParseError.MissingClosingFence, it.error) + } + } + + @Test + fun blankNameRejected() { + val raw = "---\nname: ' '\ndescription: ok\n---\nbody" + assertIs(SkillParser.parse(raw)).also { + assertEquals(SkillParseError.BlankName, it.error) + } + } + + @Test + fun invalidYaml() { + val raw = "---\nname: [unclosed\ndescription: ok\n---\nbody" + assertIs(SkillParser.parse(raw)).also { + assertIs(it.error) + } + } + + @Test + fun schemaMismatchWhenMissingDescription() { + val raw = "---\nname: only-name\n---\nbody" + assertIs(SkillParser.parse(raw)).also { + assertIs(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(r) + assertTrue(ok.value.description.contains("line one")) + assertTrue(ok.value.description.contains("line two")) + } +}