skills: каталог + ленивая загрузка read_skill
Пользовательские инструкции («навыки») живут в указанной папке
(AGENTIK_SKILLS_DIR), рекурсивно читаются при старте и попадают в
системный промпт в сжатом виде: только имя + краткое описание.
Полный текст модель подгружает по требованию, вызывая встроенный
инструмент read_skill(name).
*:skills
- SkillCatalog + SkillPrompt (commonMain): рендер секции системного
промпта; тело навыка в промпт не течёт.
- SkillParser.parseAuto(): теперь читает и opencode-стиль SKILL.md
(YAML frontmatter + markdown тело), и голый *.yaml/*.yml
(поля name, description, опц. body). parseOrThrow для strict-путей.
- SkillParseError.render(): человекочитаемое описание ошибки для
логов и диагностики.
- SkillLoader (jvmMain): рекурсивный обход папки, детерминированный
порядок (по пути), ошибки отдельных файлов не валят загрузку;
дубликаты имён → ошибка, выигрывает первый по пути.
* :standalone
- AgentikConfig.skillsDir + env AGENTIK_SKILLS_DIR.
- ChatAgent: параметр skills (SkillCatalog); системный промпт
автоматически дополняется секцией «## Навыки» и в working memory
сидится вместе с базовым промптом.
- При непустом каталоге в tools автоматически добавляется
SkillReadTool (имя read_skill) — модель может загрузить полный
текст навыка, как обычный LiteTool.
- Main.kt: загружает навыки и шумно логирует ошибки загрузки в stderr.
* docs
- STANDALONE.md: секция «Навыки (skills)», env-переменная в таблице.
- Формат SKILL.md (opencode frontmatter) + голый *.yaml/*.yml.
Тесты: :skills jvmTest 34, :standalone jvmTest 69 (новые — состав
системного промпта, регистрация read_skill, навыки не утекают в
промпт телом).
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
/**
|
||||
* Каталог загруженных скилов.
|
||||
*
|
||||
* Скилы уникальны по имени: [byName] оставляет один [SkillFile] на имя. Если
|
||||
* два файла объявляют одинаковый `name` — это ошибка загрузки, а не каталога
|
||||
* (см. [SkillLoader]).
|
||||
*/
|
||||
data class SkillCatalog(
|
||||
val skills: List<SkillFile> = emptyList(),
|
||||
) {
|
||||
/** Индекс по имени скила. */
|
||||
val byName: Map<String, SkillFile> = skills.associateBy { it.name }
|
||||
|
||||
val isEmpty: Boolean get() = skills.isEmpty()
|
||||
val size: Int get() = skills.size
|
||||
|
||||
/** Скил по имени или `null`, если такого нет. */
|
||||
fun find(name: String): SkillFile? = byName[name]
|
||||
|
||||
companion object {
|
||||
val EMPTY: SkillCatalog = SkillCatalog(emptyList())
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,13 @@ sealed interface SkillParseError {
|
||||
private fun readResolve(): Any = BlankName
|
||||
}
|
||||
}
|
||||
|
||||
/** Человекочитаемое описание ошибки — для логов и сообщений пользователю. */
|
||||
fun SkillParseError.render(): String = when (this) {
|
||||
SkillParseError.Empty -> "file is empty"
|
||||
SkillParseError.MissingOpeningFence -> "missing opening '---' fence"
|
||||
SkillParseError.MissingClosingFence -> "missing closing '---' fence"
|
||||
is SkillParseError.InvalidYaml -> "invalid YAML: $message"
|
||||
is SkillParseError.SchemaMismatch -> "frontmatter schema mismatch: $message"
|
||||
SkillParseError.BlankName -> "blank 'name'"
|
||||
}
|
||||
|
||||
@@ -32,6 +32,46 @@ object SkillParser {
|
||||
val description: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Скил целиком в одном YAML-файле (без `---`-обрамления):
|
||||
* `name`, `description`, необязательный `body`.
|
||||
*/
|
||||
@Serializable
|
||||
private data class BareFrontmatter(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val body: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Авто-режим для загрузки с диска: если файл начинается с `---` —
|
||||
* обычный fenced-формат opencode ([parse]); иначе файл трактуется как
|
||||
* «голый» YAML-объект ([BareFrontmatter]) — удобно для `*.yaml`/`*.yml`.
|
||||
*/
|
||||
fun parseAuto(raw: String): SkillParseResult {
|
||||
val text = raw.stripBom()
|
||||
if (text.trimStart().startsWith("---")) return parse(raw)
|
||||
if (text.isBlank()) return SkillParseResult.Err(SkillParseError.Empty)
|
||||
|
||||
val fm: BareFrontmatter = try {
|
||||
yaml.decodeFromString(BareFrontmatter.serializer(), text)
|
||||
} catch (e: com.charleskorn.kaml.YamlException) {
|
||||
return SkillParseResult.Err(e.toParseError())
|
||||
} 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 = fm.body.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит [raw] в [SkillFile]. Бросает [IllegalStateException] (с понятным
|
||||
* сообщением) при любой ошибке [SkillParseError].
|
||||
@@ -69,17 +109,7 @@ object SkillParser {
|
||||
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)
|
||||
return SkillParseResult.Err(e.toParseError())
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
return SkillParseResult.Err(SkillParseError.SchemaMismatch(e.message ?: "<unknown>"))
|
||||
}
|
||||
@@ -98,6 +128,21 @@ object SkillParser {
|
||||
|
||||
private fun String.stripBom(): String =
|
||||
if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this
|
||||
|
||||
/**
|
||||
* kaml кидает [com.charleskorn.kaml.YamlException] и на синтаксические
|
||||
* ошибки YAML, и на отсутствие обязательных полей. Различаем по сообщению:
|
||||
* missing-field ошибки kotlinx-serialization имеют форму
|
||||
* `Field 'description' is required ...` → относим к [SkillParseError.SchemaMismatch].
|
||||
*/
|
||||
private fun com.charleskorn.kaml.YamlException.toParseError(): SkillParseError {
|
||||
val msg = message
|
||||
return if ("is required" in msg || "Missing field" in msg) {
|
||||
SkillParseError.SchemaMismatch(msg)
|
||||
} else {
|
||||
SkillParseError.InvalidYaml(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SkillParseResult {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
/**
|
||||
* Секция системного промпта со списком доступных скилов.
|
||||
*
|
||||
* В контекст модели попадают только **имя + краткое описание** — этого
|
||||
* достаточно, чтобы модель поняла, когда скил уместен. Полный текст (`body`)
|
||||
* модель запрашивает инструментом `read_skill` (см. `SkillReadTool` в
|
||||
* `:standalone`).
|
||||
*
|
||||
* Возвращает пустую строку, если скилов нет. Порядок — как в [SkillCatalog.skills].
|
||||
*/
|
||||
fun SkillCatalog.renderSystemPromptSection(): String {
|
||||
if (isEmpty) return ""
|
||||
return buildString {
|
||||
appendLine("## Навыки")
|
||||
appendLine()
|
||||
appendLine(
|
||||
"У тебя есть специализированные навыки. Пока загружено только их название и краткое описание. " +
|
||||
"Когда задача подходит под навык — загрузи его полный текст инструментом `read_skill`, " +
|
||||
"передав имя навыка. Следуй инструкциям из загруженного навыка.",
|
||||
)
|
||||
appendLine()
|
||||
skills.forEach { skill ->
|
||||
append("- **")
|
||||
append(skill.name)
|
||||
append("**: ")
|
||||
appendLine(skill.description)
|
||||
}
|
||||
}.trimEnd()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SkillCatalogTest {
|
||||
|
||||
private val a = SkillFile(name = "alpha", description = "does alpha things", body = "SECRET BODY A")
|
||||
private val b = SkillFile(name = "beta", description = "does beta things", body = "SECRET BODY B")
|
||||
private val catalog = SkillCatalog(listOf(a, b))
|
||||
|
||||
@Test
|
||||
fun findByName() {
|
||||
assertEquals(a, catalog.find("alpha"))
|
||||
assertEquals(b, catalog.find("beta"))
|
||||
assertNull(catalog.find("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun byNameIndex() {
|
||||
assertEquals(setOf("alpha", "beta"), catalog.byName.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sizeAndEmpty() {
|
||||
assertEquals(2, catalog.size)
|
||||
assertFalse(catalog.isEmpty)
|
||||
assertTrue(SkillCatalog.EMPTY.isEmpty)
|
||||
assertEquals(0, SkillCatalog.EMPTY.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSectionEmptyForEmptyCatalog() {
|
||||
assertEquals("", SkillCatalog.EMPTY.renderSystemPromptSection())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSectionListsNameAndDescription() {
|
||||
val section = catalog.renderSystemPromptSection()
|
||||
assertTrue("alpha" in section)
|
||||
assertTrue("does alpha things" in section)
|
||||
assertTrue("beta" in section)
|
||||
assertTrue("does beta things" in section)
|
||||
assertTrue("read_skill" in section)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSectionDoesNotLeakBody() {
|
||||
val section = catalog.renderSystemPromptSection()
|
||||
assertFalse("SECRET BODY A" in section)
|
||||
assertFalse("SECRET BODY B" in section)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSectionKeepsOrder() {
|
||||
val section = catalog.renderSystemPromptSection()
|
||||
assertTrue(section.indexOf("alpha") < section.indexOf("beta"))
|
||||
}
|
||||
}
|
||||
@@ -180,4 +180,63 @@ class SkillParserTest {
|
||||
assertTrue(ok.value.description.contains("line one"))
|
||||
assertTrue(ok.value.description.contains("line two"))
|
||||
}
|
||||
|
||||
// --- parseAuto: fenced делегируется в parse, голый YAML трактуется как объект ---
|
||||
|
||||
@Test
|
||||
fun parseAutoDelegatesFencedFormat() {
|
||||
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parseAuto(sample))
|
||||
assertEquals("click-on", ok.value.name)
|
||||
assertTrue(ok.value.body.startsWith("# Click-on"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAutoBareYaml() {
|
||||
val raw = """
|
||||
name: bare
|
||||
description: no fences here
|
||||
""".trimIndent()
|
||||
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parseAuto(raw))
|
||||
assertEquals("bare", ok.value.name)
|
||||
assertEquals("no fences here", ok.value.description)
|
||||
assertEquals("", ok.value.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAutoBareYamlWithBody() {
|
||||
val raw = """
|
||||
name: bare-body
|
||||
description: d
|
||||
body: |
|
||||
# Steps
|
||||
1. do it
|
||||
""".trimIndent()
|
||||
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parseAuto(raw))
|
||||
assertEquals("bare-body", ok.value.name)
|
||||
assertTrue(ok.value.body.startsWith("# Steps"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAutoBareYamlMissingDescriptionIsSchemaMismatch() {
|
||||
val raw = "name: only-name"
|
||||
val err = assertIs<SkillParseResult.Err>(SkillParser.parseAuto(raw))
|
||||
assertIs<SkillParseError.SchemaMismatch>(err.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAutoBlankInput() {
|
||||
assertIs<SkillParseResult.Err>(SkillParser.parseAuto(" \n ")).also {
|
||||
assertEquals(SkillParseError.Empty, it.error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAutoBareYamlSupportsColonInName() {
|
||||
val raw = """
|
||||
name: backend:spring:db-base
|
||||
description: ok
|
||||
""".trimIndent()
|
||||
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parseAuto(raw))
|
||||
assertEquals("backend:spring:db-base", ok.value.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Результат загрузки папки со скилами: [catalog] с валидными скилами и
|
||||
* [errors] по битым/пропущенным файлам. Битый файл не валит всю загрузку.
|
||||
*/
|
||||
data class SkillLoadResult(
|
||||
val catalog: SkillCatalog,
|
||||
val errors: List<SkillLoadError> = emptyList(),
|
||||
)
|
||||
|
||||
/** Один проблемный файл: путь и человекочитаемая причина. */
|
||||
data class SkillLoadError(
|
||||
val path: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Загрузчик скилов с диска (JVM).
|
||||
*
|
||||
* Рекурсивно обходит папку и берёт файлы:
|
||||
* - `SKILL.md` — конвенция opencode (YAML-фронтматтер + markdown body);
|
||||
* - `*.yaml` / `*.yml` — скил целиком в YAML (`name`, `description`, опц. `body`).
|
||||
*
|
||||
* Файлы сортируются по абсолютному пути — порядок загрузки (и порядок в
|
||||
* системном промпте) детерминирован. Если два файла объявляют одинаковый
|
||||
* `name`, первый по сортировке выигрывает, второй попадает в [SkillLoadResult.errors].
|
||||
*
|
||||
* Несуществующая/нечитаемая папка — не исключение: возвращается пустой каталог
|
||||
* с одной ошибкой.
|
||||
*/
|
||||
object SkillLoader {
|
||||
|
||||
fun loadDirectory(directory: File): SkillLoadResult {
|
||||
if (!directory.isDirectory) {
|
||||
return SkillLoadResult(
|
||||
catalog = SkillCatalog.EMPTY,
|
||||
errors = listOf(SkillLoadError(directory.path, "not a directory")),
|
||||
)
|
||||
}
|
||||
|
||||
val files = directory.walkTopDown()
|
||||
.filter { it.isFile && it.isSkillFile() }
|
||||
.sortedBy { it.absolutePath }
|
||||
.toList()
|
||||
|
||||
val loaded = mutableListOf<SkillFile>()
|
||||
val errors = mutableListOf<SkillLoadError>()
|
||||
for (file in files) {
|
||||
val raw = try {
|
||||
file.readText()
|
||||
} catch (e: Throwable) {
|
||||
errors += SkillLoadError(file.absolutePath, "read failed: ${e.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
when (val result = SkillParser.parseAuto(raw)) {
|
||||
is SkillParseResult.Ok -> {
|
||||
val duplicate = loaded.any { it.name == result.value.name }
|
||||
if (duplicate) {
|
||||
errors += SkillLoadError(
|
||||
file.absolutePath,
|
||||
"duplicate skill name '${result.value.name}' (already loaded)",
|
||||
)
|
||||
} else {
|
||||
loaded += result.value
|
||||
}
|
||||
}
|
||||
is SkillParseResult.Err ->
|
||||
errors += SkillLoadError(file.absolutePath, result.error.render())
|
||||
}
|
||||
}
|
||||
|
||||
return SkillLoadResult(catalog = SkillCatalog(loaded), errors = errors)
|
||||
}
|
||||
|
||||
/**
|
||||
* `SKILL.md` — только с таким именем (регистронезависимо), чтобы не
|
||||
* подхватывать случайные markdown. `*.yaml`/`*.yml` — любые.
|
||||
*/
|
||||
private fun File.isSkillFile(): Boolean {
|
||||
val lower = name.lowercase()
|
||||
return lower == "skill.md" || lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package pw.binom.agentik.skills
|
||||
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SkillLoaderTest {
|
||||
|
||||
private fun tempDir(): File = createTempDirectory("skills-test").toFile()
|
||||
|
||||
private fun File.write(relative: String, content: String) {
|
||||
val f = File(this, relative)
|
||||
f.parentFile?.mkdirs()
|
||||
f.writeText(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadsSkillMdAndYamlRecursively() {
|
||||
val dir = tempDir()
|
||||
try {
|
||||
dir.write(
|
||||
"click/SKILL.md",
|
||||
"""
|
||||
---
|
||||
name: click
|
||||
description: click things
|
||||
---
|
||||
# Click body
|
||||
""".trimIndent(),
|
||||
)
|
||||
dir.write(
|
||||
"nested/lint.yaml",
|
||||
"""
|
||||
name: lint
|
||||
description: lint things
|
||||
body: "# Lint body"
|
||||
""".trimIndent(),
|
||||
)
|
||||
dir.write("README.md", "# not a skill")
|
||||
dir.write("notes.txt", "ignore me")
|
||||
|
||||
val result = SkillLoader.loadDirectory(dir)
|
||||
|
||||
assertEquals(emptyList(), result.errors)
|
||||
assertEquals(setOf("click", "lint"), result.catalog.byName.keys)
|
||||
assertEquals("# Click body", result.catalog.find("click")?.body)
|
||||
assertEquals("# Lint body", result.catalog.find("lint")?.body)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun brokenFileIsReportedButDoesNotFailTheLoad() {
|
||||
val dir = tempDir()
|
||||
try {
|
||||
dir.write(
|
||||
"good.yaml",
|
||||
"name: good\ndescription: fine",
|
||||
)
|
||||
dir.write("broken.yaml", "name: [unclosed")
|
||||
|
||||
val result = SkillLoader.loadDirectory(dir)
|
||||
|
||||
assertEquals(setOf("good"), result.catalog.byName.keys)
|
||||
assertEquals(1, result.errors.size)
|
||||
assertTrue(result.errors.first().path.endsWith("broken.yaml"))
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateNamesReportedAndFirstWins() {
|
||||
val dir = tempDir()
|
||||
try {
|
||||
dir.write("a.yaml", "name: same\ndescription: first")
|
||||
dir.write("b.yaml", "name: same\ndescription: second")
|
||||
|
||||
val result = SkillLoader.loadDirectory(dir)
|
||||
|
||||
assertEquals(1, result.catalog.size)
|
||||
assertEquals("first", result.catalog.find("same")?.description)
|
||||
assertEquals(1, result.errors.size)
|
||||
assertTrue("duplicate" in result.errors.first().message)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingDirectoryYieldsEmptyCatalogWithError() {
|
||||
val result = SkillLoader.loadDirectory(File("/definitely/not/here/agentik-skills"))
|
||||
assertTrue(result.catalog.isEmpty)
|
||||
assertEquals(1, result.errors.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDirectoryYieldsEmptyCatalog() {
|
||||
val dir = tempDir()
|
||||
try {
|
||||
val result = SkillLoader.loadDirectory(dir)
|
||||
assertTrue(result.catalog.isEmpty)
|
||||
assertEquals(emptyList(), result.errors)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user