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:
2026-09-14 00:25:22 +03:00
parent 3fde5c28e3
commit adcb8f54d6
17 changed files with 739 additions and 16 deletions
+41
View File
@@ -16,6 +16,9 @@
:server — HTTP+SSE фасад :proto :server — HTTP+SSE фасад :proto
public Route.agentikAgent(agent, path = "/agentik") public Route.agentikAgent(agent, path = "/agentik")
:skills — парсер и каталог навыков (KMP, commonMain + jvmMain-загрузчик)
SKILL.md (opencode frontmatter) / *.yaml, renderSystemPromptSection()
:standalone — JVM-рантайм с реальным LLM-агентом :standalone — JVM-рантайм с реальным LLM-агентом
ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google) ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google)
default 8080: default 8080:
@@ -220,6 +223,7 @@ suspend fun touch(id: String, now: Instant)
| `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` | | `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` |
| `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» | | `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» |
| `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) | | `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) |
| `AGENTIK_SKILLS_DIR` | папка с навыками (рекурсивно; `SKILL.md` или `*.yaml`/`*.yml`) | не задано (навыков нет) |
### Backend `openai` ### 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`. 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": "<skill>"}`. Модель вызывает его по необходимости, результат возвращается как обычный 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` и сериализует его под свой протокол: Каждый транспорт — отдельный модуль, который получает `Agent` и сериализует его под свой протокол:
@@ -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 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, 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] (с понятным * Парсит [raw] в [SkillFile]. Бросает [IllegalStateException] (с понятным
* сообщением) при любой ошибке [SkillParseError]. * сообщением) при любой ошибке [SkillParseError].
@@ -69,17 +109,7 @@ object SkillParser {
val fm: Frontmatter = try { val fm: Frontmatter = try {
yaml.decodeFromString(Frontmatter.serializer(), yamlText) yaml.decodeFromString(Frontmatter.serializer(), yamlText)
} catch (e: com.charleskorn.kaml.YamlException) { } catch (e: com.charleskorn.kaml.YamlException) {
// kaml кидает YamlException и на синтаксические ошибки YAML, и на return SkillParseResult.Err(e.toParseError())
// отсутствие обязательных полей. Различаем по сообщению: 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) { } catch (e: kotlinx.serialization.SerializationException) {
return SkillParseResult.Err(SkillParseError.SchemaMismatch(e.message ?: "<unknown>")) return SkillParseResult.Err(SkillParseError.SchemaMismatch(e.message ?: "<unknown>"))
} }
@@ -98,6 +128,21 @@ object SkillParser {
private fun String.stripBom(): String = private fun String.stripBom(): String =
if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this 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 { 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 one"))
assertTrue(ok.value.description.contains("line two")) 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()
}
}
}
+3
View File
@@ -38,6 +38,9 @@ kotlin {
} }
jvmMain.dependencies { jvmMain.dependencies {
// Парсер и загрузчик скилов (YAML frontmatter + markdown body).
implementation(project(":skills"))
// litert-openai: JVM-реализация // litert-openai: JVM-реализация
implementation(libs.litert.openai) implementation(libs.litert.openai)
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime // litert-google: встроенный LiteRT-LM движок, нужен только на runtime
@@ -6,10 +6,13 @@ import io.ktor.server.response.respondText
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import pw.binom.agentik.server.agentikAgent 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.agent.ChatAgent
import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.config.AgentikConfig
import pw.binom.agentik.standalone.mcp.McpRegistry import pw.binom.agentik.standalone.mcp.McpRegistry
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import java.io.File
/** /**
* standalone-контейнер agentik: * standalone-контейнер agentik:
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080) * - :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 * - AGENTIK_PORT / AGENTIK_DB_PATH
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_* * - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop) * - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
* - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`) * - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`)
*/ */
fun main() { fun main() {
@@ -37,12 +41,18 @@ fun main() {
val llm = config.llm.createLlm() val llm = config.llm.createLlm()
val stores = SqliteStores.open(dbPath = config.dbPath) val stores = SqliteStores.open(dbPath = config.dbPath)
val mcpRegistry = McpRegistry.fromConfig(config.mcp) 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( val agent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, stores = stores,
llm = llm, llm = llm,
llmConfig = config.llm, llmConfig = config.llm,
tools = mcpRegistry.namedTools, tools = mcpRegistry.namedTools,
skills = skills,
) )
val server = embeddedServer(CIO, port = config.port) { val server = embeddedServer(CIO, port = config.port) {
@@ -58,6 +68,7 @@ fun main() {
println(" storage: ${config.dbPath}") println(" storage: ${config.dbPath}")
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}") println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers") println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
println(" skills: ${skills.size} loaded${config.skillsDir?.let { " from $it" } ?: ""}")
Runtime.getRuntime().addShutdownHook(Thread { Runtime.getRuntime().addShutdownHook(Thread {
agent.close() agent.close()
mcpRegistry.close() mcpRegistry.close()
@@ -9,6 +9,8 @@ import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.proto.Agent as ProtoAgent import pw.binom.agentik.proto.Agent as ProtoAgent
import pw.binom.agentik.proto.AgentEvent import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation as ProtoConversation 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.llm.LlmConfig
import pw.binom.agentik.standalone.persistence.ConversationRecord import pw.binom.agentik.standalone.persistence.ConversationRecord
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
@@ -25,6 +27,10 @@ import kotlin.time.Instant
* логируют в SQLite (для долговечности). * логируют в SQLite (для долговечности).
* *
* Один [LiteLlm] шарится между всеми беседами агента. * Один [LiteLlm] шарится между всеми беседами агента.
*
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для
* загрузки полного текста навыка по требованию.
*/ */
class ChatAgent( class ChatAgent(
override val id: String, override val id: String,
@@ -32,8 +38,23 @@ class ChatAgent(
private val llm: LiteLlm, private val llm: LiteLlm,
private val llmConfig: LlmConfig, private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = emptyList(), private val tools: List<NamedTool> = emptyList(),
private val skills: SkillCatalog = SkillCatalog.EMPTY,
) : ProtoAgent, AutoCloseable { ) : 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<NamedTool> =
if (skills.isEmpty) tools else tools + NamedTool(SkillReadTool.NAME, SkillReadTool(skills))
private val agentEvents = MutableSharedFlow<AgentEvent>( private val agentEvents = MutableSharedFlow<AgentEvent>(
extraBufferCapacity = 64, extraBufferCapacity = 64,
) )
@@ -67,12 +88,12 @@ class ChatAgent(
stores.conversations.upsert(rec) stores.conversations.upsert(rec)
stores.workingMemory.append( stores.workingMemory.append(
conversationId = id, conversationId = id,
entry = WorkingMemoryEntry.System(text = llmConfig.systemPrompt), entry = WorkingMemoryEntry.System(text = systemPrompt),
now = now, 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 { runBlocking {
liveLock.withLock { live[conv.id] = conv } liveLock.withLock { live[conv.id] = conv }
} }
@@ -83,7 +104,7 @@ class ChatAgent(
override suspend fun getConversation(id: String): ProtoConversation? { override suspend fun getConversation(id: String): ProtoConversation? {
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it } liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
val rec = stores.conversations.get(id) ?: return null 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 } liveLock.withLock { live[id] = it }
} }
} }
@@ -99,7 +120,7 @@ class ChatAgent(
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> = override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
stores.conversations.list(offset = offset, limit = limit).map { rec -> stores.conversations.list(offset = offset, limit = limit).map { rec ->
liveLock.withLock { live[rec.id] } 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 } liveLock.withLock { live[rec.id] = it }
} }
} }
@@ -120,3 +141,12 @@ class ChatAgent(
private fun now(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis()) 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
}
@@ -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 { "<none>" }
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 }
}
}
@@ -23,6 +23,8 @@ data class AgentikConfig(
val dbPath: String = DEFAULT_DB_PATH, val dbPath: String = DEFAULT_DB_PATH,
val llm: LlmConfig, val llm: LlmConfig,
val mcp: McpConfig = McpConfig.empty(), val mcp: McpConfig = McpConfig.empty(),
/** Папка со скилами (SKILL.md / *.yaml). `null` — скилы выключены. */
val skillsDir: String? = null,
) { ) {
companion object { companion object {
const val DEFAULT_PORT: Int = 8080 const val DEFAULT_PORT: Int = 8080
@@ -38,6 +40,7 @@ data class AgentikConfig(
dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH, dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH,
llm = LlmConfig.fromEnv(env), llm = LlmConfig.fromEnv(env),
mcp = McpConfig.fromEnv(env), mcp = McpConfig.fromEnv(env),
skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() },
) )
} }
} }
@@ -10,6 +10,8 @@ import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.AgentEvent import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event as ProtoEvent 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.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
@@ -27,6 +29,7 @@ import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
@@ -53,6 +56,7 @@ class ChatAgentTest {
stores: SqliteStores = this.stores, stores: SqliteStores = this.stores,
llm: LiteLlm = this.fakeLlm, llm: LiteLlm = this.fakeLlm,
tools: List<NamedTool> = emptyList(), tools: List<NamedTool> = emptyList(),
skills: SkillCatalog = SkillCatalog.EMPTY,
): ChatAgent = ChatAgent( ): ChatAgent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, stores = stores,
@@ -63,6 +67,7 @@ class ChatAgentTest {
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"), openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
), ),
tools = tools, tools = tools,
skills = skills,
) )
@Test @Test
@@ -77,6 +82,48 @@ class ChatAgentTest {
assertEquals("be brief", first.entry.text) 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 @Test
fun `getConversation returns null for unknown id`() = runTest { fun `getConversation returns null for unknown id`() = runTest {
val agent = newAgent() val agent = newAgent()
@@ -177,7 +224,7 @@ class ChatAgentTest {
assertEquals(1, fakeLlm.conversations.size) assertEquals(1, fakeLlm.conversations.size)
val sent = fakeLlm.lastContents val sent = fakeLlm.lastContents
assertNotNull(sent) assertNotNull(sent)
assertEquals(1, sent!!.size) assertEquals(1, sent.size)
assertEquals("hello", (sent[0] as LiteContentPart.Text).text) assertEquals("hello", (sent[0] as LiteContentPart.Text).text)
} }
@@ -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(""))
}
}
@@ -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 @Test
fun `serialization round-trips through json`() { fun `serialization round-trips through json`() {
val original = AgentikConfig.fromEnv( val original = AgentikConfig.fromEnv(
@@ -110,6 +127,7 @@ class AgentikConfigTest {
"AGENTIK_PORT" to "7777", "AGENTIK_PORT" to "7777",
"AGENTIK_DB_PATH" to "/tmp/x.db", "AGENTIK_DB_PATH" to "/tmp/x.db",
"AGENTIK_SYSTEM_PROMPT" to "be brief", "AGENTIK_SYSTEM_PROMPT" to "be brief",
"AGENTIK_SKILLS_DIR" to "/skills",
), ),
), ),
).copy( ).copy(