From adcb8f54d66770efe97d2668e41a92350eb8b67a Mon Sep 17 00:00:00 2001 From: subochev Date: Mon, 14 Sep 2026 00:25:22 +0300 Subject: [PATCH] =?UTF-8?q?skills:=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=20+=20=D0=BB=D0=B5=D0=BD=D0=B8=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20read=5Fskill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пользовательские инструкции («навыки») живут в указанной папке (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, навыки не утекают в промпт телом). --- docs/STANDALONE.md | 41 +++++++ .../pw/binom/agentik/skills/SkillCatalog.kt | 25 ++++ .../binom/agentik/skills/SkillParseError.kt | 10 ++ .../pw/binom/agentik/skills/SkillParser.kt | 67 +++++++++-- .../pw/binom/agentik/skills/SkillPrompt.kt | 31 +++++ .../binom/agentik/skills/SkillCatalogTest.kt | 62 ++++++++++ .../binom/agentik/skills/SkillParserTest.kt | 59 ++++++++++ .../pw/binom/agentik/skills/SkillLoader.kt | 87 ++++++++++++++ .../binom/agentik/skills/SkillLoaderTest.kt | 111 ++++++++++++++++++ standalone/build.gradle.kts | 3 + .../pw/binom/agentik/standalone/Main.kt | 11 ++ .../agentik/standalone/agent/ChatAgent.kt | 38 +++++- .../agentik/standalone/agent/SkillReadTool.kt | 76 ++++++++++++ .../standalone/config/AgentikConfig.kt | 3 + .../agentik/standalone/agent/ChatAgentTest.kt | 49 +++++++- .../standalone/agent/SkillReadToolTest.kt | 64 ++++++++++ .../standalone/config/AgentikConfigTest.kt | 18 +++ 17 files changed, 739 insertions(+), 16 deletions(-) create mode 100644 skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillCatalog.kt create mode 100644 skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillPrompt.kt create mode 100644 skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillCatalogTest.kt create mode 100644 skills/src/jvmMain/kotlin/pw/binom/agentik/skills/SkillLoader.kt create mode 100644 skills/src/jvmTest/kotlin/pw/binom/agentik/skills/SkillLoaderTest.kt create mode 100644 standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt create mode 100644 standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt diff --git a/docs/STANDALONE.md b/docs/STANDALONE.md index e0b0da0..2551787 100644 --- a/docs/STANDALONE.md +++ b/docs/STANDALONE.md @@ -16,6 +16,9 @@ :server — HTTP+SSE фасад :proto public Route.agentikAgent(agent, path = "/agentik") +:skills — парсер и каталог навыков (KMP, commonMain + jvmMain-загрузчик) + SKILL.md (opencode frontmatter) / *.yaml, renderSystemPromptSection() + :standalone — JVM-рантайм с реальным LLM-агентом ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google) default 8080: @@ -220,6 +223,7 @@ suspend fun touch(id: String, now: Instant) | `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` | | `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» | | `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) | +| `AGENTIK_SKILLS_DIR` | папка с навыками (рекурсивно; `SKILL.md` или `*.yaml`/`*.yml`) | не задано (навыков нет) | ### Backend `openai` @@ -281,6 +285,43 @@ val agent = ChatAgent(id, stores, llm, llmConfig, tools = tools) ID у `ToolCall` и `ToolResult` разные (`tc-…` / `tr-…`), но `MessageRecord.ToolResult.toolCallId` указывает на `MessageRecord.ToolCall.id` той же логической пары. Этим достигается уникальность PK в таблице `message`. +### Навыки (skills) + +Навыки — это «лениво загружаемые» инструкции: в системный промпт попадают только **имя + краткое описание**, а полный текст модель достаёт сама, вызывая встроенный инструмент `read_skill`. + +**Формат файла** (два варианта, оба читаются): + +1. opencode-style `SKILL.md` — YAML-frontmatter + markdown-тело: + ```markdown + --- + name: backend:spring:db-base + description: MUST load before any database work. + --- + + # ... полный текст навыка ... + ``` +2. Голый YAML `*.yaml` / `*.yml` — поля `name`, `description`, опционально `body`: + ```yaml + name: lint + description: Run the linter before committing. + body: | + # Lint + Run `./gradlew detekt`. + ``` + +Загрузка: `SkillLoader.loadDirectory(dir)` рекурсивно обходит `AGENTIK_SKILLS_DIR`, парсит файлы и возвращает `SkillCatalog` + список ошибок (битый файл не валит загрузку, дубликат имени — ошибка, выигрывает первый по пути). + +**Что попадает в системный промпт** (`SkillCatalog.renderSystemPromptSection()`): блок `## Навыки` со списком `- **name**: description`. Тело навыка в промпт НЕ попадает. + +**Инструмент `read_skill`** (`SkillReadTool`) регистрируется в `ChatAgent` автоматически, если каталог непустой; для модели он выглядит как обычная функция с аргументом `{"name": ""}`. Модель вызывает его по необходимости, результат возвращается как обычный tool-result (см. tool-loop ниже). + +```kotlin +val skills = SkillLoader.loadDirectory(File(System.getenv("AGENTIK_SKILLS_DIR"))).catalog +val agent = ChatAgent(id, stores, llm, llmConfig, tools = mcpRegistry.namedTools, skills = skills) +``` + +Навык можно передать и напрямую «в тулзах» — `SkillReadTool` достаточно обернуть в `NamedTool(SkillReadTool.NAME, SkillReadTool(catalog))`, но при непустом `skills`-параметре это делается за вас. + ### Добавить ещё один транспорт Каждый транспорт — отдельный модуль, который получает `Agent` и сериализует его под свой протокол: diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillCatalog.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillCatalog.kt new file mode 100644 index 0000000..67a315a --- /dev/null +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillCatalog.kt @@ -0,0 +1,25 @@ +package pw.binom.agentik.skills + +/** + * Каталог загруженных скилов. + * + * Скилы уникальны по имени: [byName] оставляет один [SkillFile] на имя. Если + * два файла объявляют одинаковый `name` — это ошибка загрузки, а не каталога + * (см. [SkillLoader]). + */ +data class SkillCatalog( + val skills: List = emptyList(), +) { + /** Индекс по имени скила. */ + val byName: Map = 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()) + } +} diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParseError.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParseError.kt index 1ac6d7d..3aa9d34 100644 --- a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParseError.kt +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParseError.kt @@ -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'" +} diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt index 6d561fc..02fe77d 100644 --- a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillParser.kt @@ -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 ?: "")) + } + + 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 ?: "" - 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 ?: "")) } @@ -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 { diff --git a/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillPrompt.kt b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillPrompt.kt new file mode 100644 index 0000000..23225fa --- /dev/null +++ b/skills/src/commonMain/kotlin/pw/binom/agentik/skills/SkillPrompt.kt @@ -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() +} diff --git a/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillCatalogTest.kt b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillCatalogTest.kt new file mode 100644 index 0000000..dac546c --- /dev/null +++ b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillCatalogTest.kt @@ -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")) + } +} diff --git a/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt index b4e00e0..eceb325 100644 --- a/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt +++ b/skills/src/commonTest/kotlin/pw/binom/agentik/skills/SkillParserTest.kt @@ -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(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(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(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(SkillParser.parseAuto(raw)) + assertIs(err.error) + } + + @Test + fun parseAutoBlankInput() { + assertIs(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(SkillParser.parseAuto(raw)) + assertEquals("backend:spring:db-base", ok.value.name) + } } diff --git a/skills/src/jvmMain/kotlin/pw/binom/agentik/skills/SkillLoader.kt b/skills/src/jvmMain/kotlin/pw/binom/agentik/skills/SkillLoader.kt new file mode 100644 index 0000000..9476d4f --- /dev/null +++ b/skills/src/jvmMain/kotlin/pw/binom/agentik/skills/SkillLoader.kt @@ -0,0 +1,87 @@ +package pw.binom.agentik.skills + +import java.io.File + +/** + * Результат загрузки папки со скилами: [catalog] с валидными скилами и + * [errors] по битым/пропущенным файлам. Битый файл не валит всю загрузку. + */ +data class SkillLoadResult( + val catalog: SkillCatalog, + val errors: List = 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() + val errors = mutableListOf() + 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") + } +} diff --git a/skills/src/jvmTest/kotlin/pw/binom/agentik/skills/SkillLoaderTest.kt b/skills/src/jvmTest/kotlin/pw/binom/agentik/skills/SkillLoaderTest.kt new file mode 100644 index 0000000..70145b0 --- /dev/null +++ b/skills/src/jvmTest/kotlin/pw/binom/agentik/skills/SkillLoaderTest.kt @@ -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() + } + } +} diff --git a/standalone/build.gradle.kts b/standalone/build.gradle.kts index c270087..c194e1b 100644 --- a/standalone/build.gradle.kts +++ b/standalone/build.gradle.kts @@ -38,6 +38,9 @@ kotlin { } jvmMain.dependencies { + // Парсер и загрузчик скилов (YAML frontmatter + markdown body). + implementation(project(":skills")) + // litert-openai: JVM-реализация implementation(libs.litert.openai) // litert-google: встроенный LiteRT-LM движок, нужен только на runtime diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt index dd441c4..4c00ec4 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -6,10 +6,13 @@ import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.routing import pw.binom.agentik.server.agentikAgent +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillLoader import pw.binom.agentik.standalone.agent.ChatAgent import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.mcp.McpRegistry import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores +import java.io.File /** * standalone-контейнер agentik: * - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080) @@ -29,6 +32,7 @@ import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores * - AGENTIK_PORT / AGENTIK_DB_PATH * - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_* * - MCP: AGENTIK_MCP_CONFIG=.json (формат Claude Desktop) + * - Skills: AGENTIK_SKILLS_DIR= (папка с SKILL.md / *.yaml) * - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`) */ fun main() { @@ -37,12 +41,18 @@ fun main() { val llm = config.llm.createLlm() val stores = SqliteStores.open(dbPath = config.dbPath) val mcpRegistry = McpRegistry.fromConfig(config.mcp) + val skills = config.skillsDir?.let { dir -> + val result = SkillLoader.loadDirectory(File(dir)) + result.errors.forEach { System.err.println("[agentik] skill '${it.path}': ${it.message}") } + result.catalog + } ?: SkillCatalog.EMPTY val agent = ChatAgent( id = "agentik", stores = stores, llm = llm, llmConfig = config.llm, tools = mcpRegistry.namedTools, + skills = skills, ) val server = embeddedServer(CIO, port = config.port) { @@ -58,6 +68,7 @@ fun main() { println(" storage: ${config.dbPath}") println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}") println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers") + println(" skills: ${skills.size} loaded${config.skillsDir?.let { " from $it" } ?: ""}") Runtime.getRuntime().addShutdownHook(Thread { agent.close() mcpRegistry.close() diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index 9e6db78..57a2129 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.sync.withLock import pw.binom.agentik.proto.Agent as ProtoAgent import pw.binom.agentik.proto.AgentEvent import pw.binom.agentik.proto.Conversation as ProtoConversation +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.renderSystemPromptSection import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.persistence.ConversationRecord import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry @@ -25,6 +27,10 @@ import kotlin.time.Instant * логируют в SQLite (для долговечности). * * Один [LiteLlm] шарится между всеми беседами агента. + * + * Если задан [skills], их каталог (имя + краткое описание) подмешивается в + * системный промпт, а в набор тулов добавляется встроенный `read_skill` для + * загрузки полного текста навыка по требованию. */ class ChatAgent( override val id: String, @@ -32,8 +38,23 @@ class ChatAgent( private val llm: LiteLlm, private val llmConfig: LlmConfig, private val tools: List = emptyList(), + private val skills: SkillCatalog = SkillCatalog.EMPTY, ) : ProtoAgent, AutoCloseable { + /** + * Системный промпт + секция навыков (если скилы загружены). Именно он + * сидируется в working memory и передаётся в [ChatConversation]. + */ + private val systemPrompt: String = buildSystemPrompt(llmConfig.systemPrompt, skills) + + /** + * Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенный + * `read_skill`, если есть скилы. MCP-тулы префиксованы `server__`, так что + * коллизия с `read_skill` невозможна. + */ + private val allTools: List = + if (skills.isEmpty) tools else tools + NamedTool(SkillReadTool.NAME, SkillReadTool(skills)) + private val agentEvents = MutableSharedFlow( extraBufferCapacity = 64, ) @@ -67,12 +88,12 @@ class ChatAgent( stores.conversations.upsert(rec) stores.workingMemory.append( conversationId = id, - entry = WorkingMemoryEntry.System(text = llmConfig.systemPrompt), + entry = WorkingMemoryEntry.System(text = systemPrompt), now = now, ) } } - val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools) + val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools) runBlocking { liveLock.withLock { live[conv.id] = conv } } @@ -83,7 +104,7 @@ class ChatAgent( override suspend fun getConversation(id: String): ProtoConversation? { liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it } val rec = stores.conversations.get(id) ?: return null - return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools).also { + return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools).also { liveLock.withLock { live[id] = it } } } @@ -99,7 +120,7 @@ class ChatAgent( override suspend fun getConversations(offset: Int, limit: Int): List = stores.conversations.list(offset = offset, limit = limit).map { rec -> liveLock.withLock { live[rec.id] } - ?: ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt).also { + ?: ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools).also { liveLock.withLock { live[rec.id] = it } } } @@ -120,3 +141,12 @@ class ChatAgent( private fun now(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis()) } + +/** + * Собирает итоговый системный промпт: базовый текст + секция навыков + * (только если скилы есть). Пустая секция → базовый промпт без изменений. + */ +internal fun buildSystemPrompt(base: String, skills: SkillCatalog): String { + val section = skills.renderSystemPromptSection() + return if (section.isBlank()) base.trimEnd() else base.trimEnd() + "\n\n" + section +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt new file mode 100644 index 0000000..9cf202f --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillReadTool.kt @@ -0,0 +1,76 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.litert.LiteTool + +/** + * Встроенная тула `read_skill`: отдаёт полный текст скила по имени. + * + * В системный промпт попадают только имя и краткое описание скилов + * (см. `SkillCatalog.renderSystemPromptSection()`); этот тул загружает `body` + * по требованию модели. Если имя неизвестно — возвращаем список доступных + * имён, чтобы модель могла исправиться со следующей попытки. + */ +class SkillReadTool( + private val catalog: SkillCatalog, +) : LiteTool { + + override fun describe(): String = buildJsonObject { + put("type", "function") + put("function", buildJsonObject { + put("name", NAME) + put( + "description", + "Load the full text of a skill by its name. " + + "Use it when the task matches one of the skills listed in the system prompt. " + + "Always read a skill before following its instructions.", + ) + put("parameters", buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("name", buildJsonObject { + put("type", "string") + put("description", "Skill name exactly as listed in the system prompt.") + }) + }) + put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name")))) + }) + }) + }.toString() + + override fun invoke(arguments: String): String { + val name = parseName(arguments) + ?: return "[tool error] read_skill: missing required argument \"name\"" + + val skill = catalog.find(name) + ?: return "[tool error] unknown skill \"$name\". Available skills: " + + catalog.skills.joinToString(", ") { it.name }.ifEmpty { "" } + + return if (skill.body.isBlank()) { + "(skill \"$name\" has an empty body)" + } else { + skill.body + } + } + + private fun parseName(arguments: String): String? { + val raw = arguments.trim() + if (raw.isEmpty()) return null + val obj: JsonObject = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null + return (obj["name"] as? JsonPrimitive)?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() } + } + + companion object { + /** Имя тула, как его видит модель. */ + const val NAME: String = "read_skill" + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt index ae496e6..68e0e4e 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt @@ -23,6 +23,8 @@ data class AgentikConfig( val dbPath: String = DEFAULT_DB_PATH, val llm: LlmConfig, val mcp: McpConfig = McpConfig.empty(), + /** Папка со скилами (SKILL.md / *.yaml). `null` — скилы выключены. */ + val skillsDir: String? = null, ) { companion object { const val DEFAULT_PORT: Int = 8080 @@ -38,6 +40,7 @@ data class AgentikConfig( dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH, llm = LlmConfig.fromEnv(env), mcp = McpConfig.fromEnv(env), + skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() }, ) } } diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index b005002..f63ac23 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.test.runTest import pw.binom.agentik.proto.AgentEvent import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Event as ProtoEvent +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores @@ -27,6 +29,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -53,6 +56,7 @@ class ChatAgentTest { stores: SqliteStores = this.stores, llm: LiteLlm = this.fakeLlm, tools: List = emptyList(), + skills: SkillCatalog = SkillCatalog.EMPTY, ): ChatAgent = ChatAgent( id = "agentik", stores = stores, @@ -63,6 +67,7 @@ class ChatAgentTest { openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), ), tools = tools, + skills = skills, ) @Test @@ -77,6 +82,48 @@ class ChatAgentTest { assertEquals("be brief", first.entry.text) } + @Test + fun `skills are appended to the system prompt in working memory`() = runTest { + val skills = SkillCatalog( + listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), + ) + val agent = newAgent(skills = skills) + val conv = agent.createConversation(temp = false) as ChatConversation + + val system = stores.workingMemory.list(conv.id).first().entry + as pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.System + assertTrue("be brief" in system.text) + assertTrue("## Навыки" in system.text) + assertTrue("lint" in system.text) + assertTrue("lint things" in system.text) + assertFalse("SECRET BODY" in system.text, "system prompt must not leak the skill body") + } + + @Test + fun `read_skill tool is registered when skills present`() = runTest { + val skills = SkillCatalog( + listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), + ) + val agent = newAgent(skills = skills) + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + + val descriptors = fakeLlm.lastConfig!!.tools.map { it.describe() } + assertTrue(descriptors.any { SkillReadTool.NAME in it }, "expected read_skill tool: $descriptors") + } + + @Test + fun `no read_skill tool when skills absent`() = runTest { + val agent = newAgent() + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + + val descriptors = fakeLlm.lastConfig?.tools?.map { it.describe() } ?: emptyList() + assertTrue(descriptors.none { SkillReadTool.NAME in it }, "unexpected read_skill tool: $descriptors") + } + @Test fun `getConversation returns null for unknown id`() = runTest { val agent = newAgent() @@ -177,7 +224,7 @@ class ChatAgentTest { assertEquals(1, fakeLlm.conversations.size) val sent = fakeLlm.lastContents assertNotNull(sent) - assertEquals(1, sent!!.size) + assertEquals(1, sent.size) assertEquals("hello", (sent[0] as LiteContentPart.Text).text) } diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt new file mode 100644 index 0000000..2ff3a0a --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillReadToolTest.kt @@ -0,0 +1,64 @@ +package pw.binom.agentik.standalone.agent + +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.skills.SkillFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SkillReadToolTest { + + private val catalog = SkillCatalog( + listOf( + SkillFile(name = "lint", description = "lint things", body = "# Lint\nRun the linter."), + SkillFile(name = "empty", description = "no body", body = ""), + ), + ) + private val tool = SkillReadTool(catalog) + + @Test + fun describeIsOpenAiFunctionSchema() { + val json = tool.describe() + assertTrue("\"type\":\"function\"" in json || "\"type\": \"function\"" in json) + assertTrue(SkillReadTool.NAME in json) + assertTrue("\"name\"" in json) + assertTrue("parameters" in json) + } + + @Test + fun invokeReturnsBodyForKnownSkill() { + val result = tool.invoke("""{"name":"lint"}""") + assertEquals("# Lint\nRun the linter.", result) + } + + @Test + fun invokeUnknownSkillListsAvailable() { + val result = tool.invoke("""{"name":"nope"}""") + assertTrue("unknown skill" in result) + assertTrue("lint" in result) + assertTrue("empty" in result) + } + + @Test + fun invokeEmptyBodyGivesPlaceholder() { + val result = tool.invoke("""{"name":"empty"}""") + assertTrue("empty body" in result) + } + + @Test + fun invokeMissingNameIsError() { + val result = tool.invoke("{}") + assertTrue("[tool error]" in result) + assertTrue("name" in result) + } + + @Test + fun invokeInvalidJsonIsError() { + assertTrue("[tool error]" in tool.invoke("not json")) + } + + @Test + fun invokeBlankArgumentsIsError() { + assertTrue("[tool error]" in tool.invoke("")) + } +} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt index 7758aee..eed1007 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt @@ -102,6 +102,23 @@ class AgentikConfigTest { } } + @Test + fun `skills dir defaults to null`() { + assertEquals(null, AgentikConfig.fromEnv(openAiEnv()).skillsDir) + } + + @Test + fun `skills dir read from env`() { + val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills"))) + assertEquals("/skills", cfg.skillsDir) + } + + @Test + fun `blank skills dir falls back to null`() { + val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " "))) + assertEquals(null, cfg.skillsDir) + } + @Test fun `serialization round-trips through json`() { val original = AgentikConfig.fromEnv( @@ -110,6 +127,7 @@ class AgentikConfigTest { "AGENTIK_PORT" to "7777", "AGENTIK_DB_PATH" to "/tmp/x.db", "AGENTIK_SYSTEM_PROMPT" to "be brief", + "AGENTIK_SKILLS_DIR" to "/skills", ), ), ).copy(