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