Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s

This commit is contained in:
2026-09-23 03:48:38 +03:00
parent 84f5fd84f3
commit e447525059
105 changed files with 2732 additions and 1978 deletions
@@ -0,0 +1,78 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
private val log = mu.KotlinLogging.logger {}
/**
* Фоновый минер скилов (skill mining): сетка безопасности для skill self-improvement.
*
* Модель в ходе разговора может "протупить" и не вызвать `skill_save`, хотя приём
* был действительно переиспользуемым. [SkillMiner] периодически (каждые N ходов,
* см. `AGENTIK_SKILL_MINING_INTERVAL`) берёт последние ходы диалога, показывает их
* LLM вместе с текущим каталогом скилов и просит structured-output JSON:
* {"skills": [{"name","description","body"}]}. Найденные скилы upsert-ятся в
* [pw.binom.agentik.skills.SkillStore] — агент становится умнее между сессиями
* даже без прямого tool-call в ходе разговора.
*
* Архитектурно — точный аналог [LlmReflector]: короткоживущий LiteConversation
* (один прогон = один LLM-вызов), blocking-инференс на [dispatcher], defensive
* парсинг [SkillMiningParser] (кривой ответ → пустой список, не ломает agent loop).
*
* @param llm LLM-бэкенд
* @param maxTurns сколько последних ходов передавать модели (default 30)
* @param maxTokens потолок ответа модели (default 1536 — body скила бывает длинным)
* @param dispatcher диспетчер для блокирующего LLM-вызова
*/
class SkillMiner(
private val llm: LiteLlm,
val maxTurns: Int = 30,
private val maxTokens: Int = 1536,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
/**
* Mine по последним [turns] с учётом текущего каталога [existing].
*
* @return найденные/обновлённые скилы; пустой список — нечего сохранять
* или модель ответила мусором (defensive: прогон просто пропускается).
*
* Вызов блокирующий: ~1-3 с на CPU для on-device LiteRT-LM. Всегда вызывать
* из background scope (хук [pw.binom.agentik.standalone.agent.ChatConversation]
* или debug-эндпоинт).
*/
suspend fun mine(turns: List<ConversationTurn>, existing: List<SkillFile>): List<SkillFile> =
withContext(dispatcher) {
val batch = turns.takeLast(maxTurns)
if (batch.isEmpty()) return@withContext emptyList()
val conversation = llm.createConversation(
config = LiteConversationConfig(
systemInstruction = SkillMiningPrompts.SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
temperature = 0.2f,
maxTokens = maxTokens,
)
)
try {
val userPrompt = SkillMiningPrompts.buildUserPrompt(batch, existing)
val raw = conversation.send(userPrompt)
val mined = SkillMiningParser.parse(raw)
if (mined.isNotEmpty()) {
log.info { "skill-mine: found ${mined.size} skill(s) from ${batch.size} turns: ${mined.map { it.name }}" }
}
mined
} catch (e: Throwable) {
log.warn(e) { "skill-mine: LLM call failed, skipping pass" }
emptyList()
} finally {
conversation.close()
}
}
}
@@ -0,0 +1,154 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.ConversationAware
import pw.binom.agentik.agent.ConversationHandle
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.skills.renderSystemPromptSection
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteTool
/**
* События, по которым SkillMiningComponent решает, что пора майнить новые скилы.
*
* Standalone-часть мэпит свой [pw.binom.agentik.standalone.agent.BackgroundEventBus]
* на этот sealed interface и подаёт результат в [SkillMiningComponent.events].
* Делаем так, чтобы модуль :skill-mining не зависел от :standalone и его
* внутренних типов.
*/
sealed interface SkillMiningEvent {
val conversationId: String
/** Разговор закрывается — последний шанс вытащить скилы из контекста. */
data class ConversationClosing(override val conversationId: String) : SkillMiningEvent
/** Сработал compactor — старые turn'ы ушли, mining нужен на новом контексте. */
data class ConversationCompacted(override val conversationId: String) : SkillMiningEvent
}
/**
* Полная подсистема скилов как один Component:
* 1. Тулы skill_save / skill_delete / skill_read — через [SkillToolProvider].
* 2. Секция системного промпта с актуальным каталогом — через [SkillSystemProvider].
* 3. Фоновый майнер — на [SkillMiningEvent.ConversationClosing] /
* [SkillMiningEvent.ConversationCompacted] вызывает [SkillMiner] над
* последними turn'ами разговора и пишет новые скилы в [SkillStore].
*
* Поведение майнера зависит от per-conversation state ([ConversationHandle]):
* - isTemporal=true → пропускаем (mining не имеет смысла для temp-бесед);
* - recentTurns(limit) → что майнер видит.
*
* Подключается как обычный Component: `agent.install(SkillMiningComponent(...))`.
* Дополнительно компонент реализует [ConversationAware] — [MutableAgent]
* оповещает его о создании/закрытии каждого разговора.
*/
class SkillMiningComponent(
private val store: SkillStore,
private val llm: LiteLlm,
private val events: Flow<SkillMiningEvent>,
private val scope: CoroutineScope,
) : Component, ConversationAware {
private var toolProvider: SkillToolProvider? = null
private var systemProvider: SkillSystemProvider? = null
private var minerJob: Job? = null
/** Per-conversation context для mining: id → handle. */
private val handles = mutableMapOf<String, ConversationHandle>()
private val handlesLock = Mutex()
override fun install(agent: MutableAgent) {
val tp = SkillToolProvider(store)
agent.toolProviders.add(tp)
toolProvider = tp
val sp = SkillSystemProvider(store)
agent.systemProviders.add(sp)
systemProvider = sp
// Подписка на фоновые события — единая на компонент (не на разговор).
minerJob = scope.launch {
events.collect { event ->
when (event) {
is SkillMiningEvent.ConversationClosing,
is SkillMiningEvent.ConversationCompacted -> {
mineForConversation(event.conversationId)
}
}
}
}
}
override fun uninstall(agent: MutableAgent) {
minerJob?.cancel()
minerJob = null
toolProvider?.let { agent.toolProviders.remove(it) }
systemProvider?.let { agent.systemProviders.remove(it) }
// очистка под mutex: detach-вызовы могут быть в полёте из других корутин
runBlocking { handlesLock.withLock { handles.clear() } }
}
/** [ConversationAware]: новый разговор — запоминаем handle. */
override fun attachConversation(handle: ConversationHandle) {
// attach вызывается синхронно из MutableAgent.install-flow,
// целостность важна только между attach/detach и чтением из mining-корутины
runBlocking { handlesLock.withLock { handles[handle.id] = handle } }
}
/** [ConversationAware]: разговор закрыт — забываем handle. */
override fun detachConversation(handle: ConversationHandle) {
runBlocking { handlesLock.withLock { handles.remove(handle.id) } }
}
private fun mineForConversation(conversationId: String) {
// горячий путь: одна mining-корутина читает snapshot handle и идёт работать.
// блокировка короткая, copy-on-read не нужно — handle'ы immutable в нашей модели.
val handle = runBlocking { handlesLock.withLock { handles[conversationId] } } ?: return
if (handle.isTemporal) return
scope.launch {
val miner = SkillMiner(llm)
val turns = runCatching { handle.recentTurns(miner.maxTurns) }.getOrDefault(emptyList())
if (turns.isEmpty()) return@launch
runCatching {
// ConversationTurn (agent-api) == ConversationTurn (memory-api) через typealias.
miner.mine(turns, store.catalog.skills)
}
.onSuccess { mined ->
mined.forEach { runCatching { store.upsert(it) } }
}
}
}
}
private class SkillToolProvider(
private val store: SkillStore,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> {
val catalog: SkillCatalog = store.catalog
return buildList {
add(SkillSaveTool(store))
add(SkillDeleteTool(store))
if (!catalog.isEmpty) add(SkillReadTool(catalog))
}
}
}
private class SkillSystemProvider(
private val store: SkillStore,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String =
store.catalog.renderSystemPromptSection()
}
@@ -0,0 +1,207 @@
package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillFile
/**
* Минимальный парсер JSON-ответа [SkillMiner] (в том же defensive-стиле, что
* [ReflectionParser] и [ReviewDecisionParser]: без kotlinx-serialization, чтобы
* кривой ответ локальной модели не ронял agent loop).
*
* Ожидаемая форма:
* ```
* {"skills": [{"name": "x", "description": "...", "body": "..."}]}
* ```
* Допуски:
* - ответ может быть обёрнут в ```json ... ``` fences;
* - может быть текст до/после JSON — ищем первый `{` (или `[`) и балансируем;
* - допустим и голый массив `[{...}, {...}]` без ключа `"skills"`;
* - `body` может содержать `\n`, `\"`, `\\` — деэскейпим;
* - `description`/`body` могут отсутствовать (тогда пустые);
* - любой мусор (невалидные кавычки, незакрытые скобки) → пустой список,
* mining-прогон просто не сохранит ничего.
*/
object SkillMiningParser {
fun parse(raw: String): List<SkillFile> {
val blob = extractJsonBlob(raw) ?: return emptyList()
val array = extractSkillsArray(blob) ?: return emptyList()
return splitTopLevelObjects(array).mapNotNull { obj ->
val name = extractStringField(obj, "name")?.trim().orEmpty()
if (name.isEmpty()) return@mapNotNull null
val description = extractStringField(obj, "description")?.trim().orEmpty()
val body = extractStringField(obj, "body")?.trim().orEmpty()
SkillFile(name = name, description = description, body = body)
}
}
/**
* Обрезает ``` fences, находит первый `{` или `[` и возвращает подстроку
* до парной закрывающей (со знанием состояний string/escape).
*/
internal fun extractJsonBlob(raw: String): String? {
var s = raw.trim()
if (s.startsWith("```")) {
val nl = s.indexOf('\n')
if (nl > 0) s = s.substring(nl + 1)
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
}
val open = minOf(
s.indexOf('{').takeIf { it >= 0 } ?: Int.MAX_VALUE,
s.indexOf('[').takeIf { it >= 0 } ?: Int.MAX_VALUE,
)
if (open == Int.MAX_VALUE) return null
val opener = s[open]
val closer = if (opener == '{') '}' else ']'
var depth = 0
var inString = false
var escape = false
for (i in open until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
opener, '{', '[' -> depth++
'}', ']' -> {
depth--
if (depth == 0 && ((c == closer) || (opener == '{' && c == '}') || (opener == '[' && c == ']'))) {
return s.substring(open, i + 1)
}
}
}
}
return null
}
/**
* Находит массив скилов: если в blob есть ключ `"skills"` — массив после него,
* иначе сам blob (если начинается с `[`).
*/
internal fun extractSkillsArray(blob: String): String? {
val keyIdx = blob.indexOf("\"skills\"")
if (keyIdx >= 0) {
val colon = blob.indexOf(':', keyIdx + "skills".length + 2)
if (colon < 0) return null
val open = blob.indexOf('[', colon + 1)
if (open < 0) return null
return balanceArray(blob, open)
}
if (blob.startsWith('[')) return blob.drop(1).dropLast(1)
return null
}
/** Балансирует `[...]` от [start] (включительно). Возвращает содержимое без скобок. */
private fun balanceArray(s: String, start: Int): String? {
var depth = 0
var inString = false
var escape = false
for (i in start until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
'[' -> depth++
']' -> {
depth--
if (depth == 0) return s.substring(start + 1, i)
}
}
}
return null
}
/** Разбивает содержимое массива на топ-уровневые `{...}` объекты (string-aware). */
internal fun splitTopLevelObjects(arrayContent: String): List<String> {
val out = mutableListOf<String>()
var i = 0
while (i < arrayContent.length) {
if (arrayContent[i] == '{') {
var depth = 0
var inString = false
var escape = false
var j = i
while (j < arrayContent.length) {
val c = arrayContent[j]
if (escape) { escape = false; j++; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
} else {
when (c) {
'"' -> inString = true
'{' -> depth++
'}' -> {
depth--
if (depth == 0) {
out.add(arrayContent.substring(i, j + 1))
i = j + 1
break
}
}
}
}
j++
}
if (j >= arrayContent.length) break
} else {
i++
}
}
return out
}
/**
* Достаёт первое строковое поле `"<name>": "..."` из JSON-объекта с
* поддержкой escapes (`\"`, `\\`, `\n`, `\t`). `null` если поля нет.
*/
internal fun extractStringField(obj: String, name: String): String? {
val keyRe = Regex(""""$name"\s*:""")
val keyMatch = keyRe.find(obj) ?: return null
val colonIdx = keyMatch.range.last
// Пропускаем пробельные символы после :
var i = colonIdx + 1
while (i < obj.length && (obj[i] == ' ' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == '\t')) i++
if (i >= obj.length || obj[i] != '"') {
// Значение не строка (null/число) — не поддерживаем.
return null
}
i++ // открывающая кавычка
val sb = StringBuilder()
while (i < obj.length) {
val c = obj[i]
if (c == '\\' && i + 1 < obj.length) {
when (val esc = obj[i + 1]) {
'"' -> sb.append('"')
'\\' -> sb.append('\\')
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
else -> sb.append(esc)
}
i += 2
} else if (c == '"') {
return sb.toString()
} else {
sb.append(c)
i++
}
}
// Незакрытая строка — мусор от модели, считаем null.
return null
}
}
@@ -0,0 +1,80 @@
package pw.binom.agentik.skill.mining
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
/**
* Промпты для [SkillMiner]: structured-output JSON.
*
* Тот же подход, что [ReflectionPrompts] и [LlmMemoryReviewer]: локальной модели
* (LiteRT-LM) не доверяем tool-calling, поэтому просим строго JSON и парсим руками
* ([SkillMiningParser]).
*/
object SkillMiningPrompts {
/**
* System prompt. Задаёт роль "минёра скилов": модель смотрит на последние
* ходы разговора и решает, есть ли в них переиспользуемый приём, который
* стоит закрепить в скиле, чтобы агент становился умнее между сессиями.
*
* Ключевое отличие от `skill_save` (в-ходе): mining — это сетка безопасности,
* если модель в ходе разговора забыла сохранить скил. Поэтому промпт жёсткий
* по критериям: только реально переиспользуемое, без дублей каталога.
*/
val SYSTEM_PROMPT: String = """
Ты — фоновый минер навыков (skill miner) для ИИ-агента.
Тебе показывают последние ходы разговора агента с пользователем и каталог
его текущих навыков (скилов). Твоя задача — найти в этих ходах переиспользуемый
приём, процедуру или паттерн, который агент применит в БУДУЩИХ, других
разговорах. Такой приём закрепляется как скил, и агент с ним становится
умнее.
Критерии, ЧТО сохращать:
- конкретная воспроизводимая процедура (шаги, команды, форматы, приёмы);
- решение проблемы, которое модель выработала и в другом разговоре повторит;
- проверка/валидация, о которой модель "забыла" и её стоит закрепить.
ЧТО НЕ сохраняй:
- разовые факты конкретного разговора (это не приём, а данные);
- тривиальность ("ответь кратко"), которую и так видно из контекста;
- дубли уже существующих скилов в каталоге — если приём уже есть,
предложи ОБНОВЛЕНИЕ (то же имя, улучшённый body), а не новый скил.
Вывод — строго JSON, без пояснений до и после:
{"skills": [{"name": "...", "description": "...", "body": "..."}]}
Если сохранять нечего — {"skills": []}
Правила по полям:
- name: kebab-case или иерархия через двоеточие (например, "backend:spring:db-base");
короткое, 2-5 слов. Если обновляешь существующий скил — ТОЧНО его имя.
- description: 1-2 предложения, когда/зачем применять скил.
- body: markdown-инструкция: краткое описание + нумерованные шаги + примеры.
Только то, что агент должен помнить; без воды.
""".trimIndent()
/**
* User prompt: последние [turns] разговора + каталог существующих скилов.
* Ходы нумеруются, чтобы модель понимала хронологию.
*/
fun buildUserPrompt(turns: List<ConversationTurn>, existing: List<SkillFile>): String = buildString {
appendLine("### Последние ходы разговора (по хронологии)")
turns.forEachIndexed { i, t ->
appendLine()
appendLine("--- Ход ${i + 1} ---")
appendLine("[user] ${t.userMessage.take(1500)}")
appendLine("[assistant] ${t.assistantMessage.take(2000)}")
}
appendLine()
appendLine("### Текущий каталог скилов (не дубли, обновляй при необходимости)")
if (existing.isEmpty()) {
appendLine("(пока нет)")
} else {
for (s in existing) {
appendLine("- ${s.name}: ${s.description.take(160)}")
}
}
appendLine()
appendLine("Если есть что сохранить (или обновить существующий) — выведи JSON. Иначе {\"skills\": []}.")
}
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.skill.mining
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 val name: String get() = NAME
override fun describe(): String = buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
// OpenAI-формат {"type":"function","function":{...}}.
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 }
}
}
@@ -0,0 +1,161 @@
package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore
import pw.binom.litert.LiteTool
/**
* Тул `skill_save(name, description, body)` — сохраняет скил через [SkillStore].
*
* Схема аргументов (function-calling JSON):
* ```
* {
* "name": "skill_save",
* "description": "Создать или обновить скил. Имя может содержать двоеточия (как в opencode: 'backend:spring:db-base').",
* "parameters": {
* "type": "object",
* "properties": {
* "name": {"type": "string", "description": "Имя скила, уникальное в каталоге."},
* "description": {"type": "string", "description": "Одно-два предложения: когда применять."},
* "body": {"type": "string", "description": "Markdown body скила."}
* },
* "required": ["name", "description", "body"]
* }
* }
* ```
*/
class SkillSaveTool(
private val store: SkillStore,
) : LiteTool {
override val name: String = "skill_save"
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val parsed = parseArgs(arguments)
?: throw IllegalStateException("invalid arguments: $arguments")
val (name, description, body) = parsed
return try {
store.upsert(SkillFile(name = name, description = description, body = body))
"""{"ok":true,"name":"${escape(name)}"}"""
} catch (e: Exception) {
throw IllegalStateException(e.message ?: "upsert failed")
}
}
private fun parseArgs(arguments: String): Triple<String, String, String>? {
val name = extractString(arguments, "name") ?: return null
val description = extractString(arguments, "description") ?: return null
val body = extractString(arguments, "body") ?: return null
return Triple(name, description, body)
}
companion object {
private const val SCHEMA = """
{
"name": "skill_save",
"description": "Создать или обновить скил. Имя может содержать двоеточия (например 'backend:spring:db-base'). Скил станет доступен в этом и следующих сеансах через read_skill.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"body": {"type": "string"}
},
"required": ["name", "description", "body"]
}
}
"""
}
}
/**
* Тул `skill_delete(name)` — архивирует скил.
*/
class SkillDeleteTool(
private val store: SkillStore,
) : LiteTool {
override val name: String = "skill_delete"
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val name = extractString(arguments, "name")
?: throw IllegalStateException("missing 'name' in arguments: $arguments")
return try {
val removed = store.remove(name)
if (removed) {
"""{"ok":true,"archived":"${escape(name)}"}"""
} else {
throw IllegalStateException("skill '$name' not found")
}
} catch (e: Exception) {
throw IllegalStateException(e.message ?: "delete failed")
}
}
companion object {
private const val SCHEMA = """
{
"name": "skill_delete",
"description": "Архивировать скил по имени. Скил больше не будет появляться в read_skill, но файл остаётся на диске с суффиксом .archived.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
}
}
"""
}
}
/**
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
* JSON простой (плоский объект с известным набором ключей).
*/
internal fun extractString(json: String, key: String): String? {
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return null
val colon = json.indexOf(':', keyIdx)
if (colon < 0) return null
val firstQuote = json.indexOf('"', colon)
if (firstQuote < 0) return null
var i = firstQuote + 1
val sb = StringBuilder()
while (i < json.length) {
val c = json[i]
when {
c == '\\' && i + 1 < json.length -> {
when (val next = json[i + 1]) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(next)
}
i += 2
}
c == '"' -> return sb.toString()
else -> {
sb.append(c)
i++
}
}
}
return null
}
/** JSON-escape строки. */
internal fun escape(s: String): String = buildString(s.length + 2) {
for (c in s) {
when (c) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(c)
}
}
}
@@ -0,0 +1,127 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -0,0 +1,66 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.ConversationTurn
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.skill.mining.SkillMiner
class SkillMinerTest {
private fun turns(n: Int): List<ConversationTurn> = (1..n).map {
ConversationTurn(userMessage = "q$it", assistantMessage = "a$it")
}
@Test
fun `mine returns parsed skills from model JSON reply`() = runBlocking {
val llm = FakeLiteLlm()
llm.reply = """{"skills": [{"name": "n", "description": "d", "body": "b"}]}"""
val miner = SkillMiner(llm, maxTurns = 10, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
val out = miner.mine(turns(5), existing = emptyList())
assertEquals(1, out.size)
assertEquals("n", out[0].name)
}
@Test
fun `mine with empty reply returns empty`() = runBlocking {
val llm = FakeLiteLlm()
llm.reply = "{}"
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
}
@Test
fun `miner failure degrades to empty list`() = runBlocking {
val llm = FakeLiteLlm()
llm.failMessage = "onnx died"
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
}
@Test
fun `miner respects maxTurns`() = runBlocking {
val llm = FakeLiteLlm()
llm.reply = """{"skills": []}"""
val miner = SkillMiner(llm, maxTurns = 2, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
miner.mine(turns(30), emptyList())
val prompt = llm.lastContents!!.first().let {
val p = it as pw.binom.litert.LiteContentPart.Text
p.text
}
// В промпт попало только последние 2 хода из 30.
assertTrue(prompt.contains("q29"), "last turns missing: $prompt")
assertTrue(!prompt.contains("q1\n"), "old turn leaked: $prompt")
}
@Test
fun `empty turns short-circuit without LLM call`() = runBlocking {
val llm = FakeLiteLlm()
llm.failMessage = "should not be called"
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
val out = miner.mine(emptyList(), emptyList())
assertTrue(out.isEmpty())
assertTrue(llm.conversations.isEmpty(), "LLM must not be called for empty input")
}
}
@@ -0,0 +1,82 @@
package pw.binom.agentik.skill.mining
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.skill.mining.SkillMiningParser
class SkillMiningParserTest {
@Test
fun `parses clean JSON`() {
val raw = """{"skills": [{"name": "backend:spring:db", "description": "x", "body": "# step 1"}]}"""
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("backend:spring:db", out[0].name)
assertEquals("x", out[0].description)
assertEquals("# step 1", out[0].body)
}
@Test
fun `parses JSON wrapped in fences with prose around`() {
val raw = "Окей, вот что я нашёл:\n```json\n" +
"{\"skills\": [{\"name\": \"a\", \"description\": \"d\", \"body\": \"b\"}]}\n" +
"```\nНадеюсь, помогло."
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("a", out[0].name)
}
@Test
fun `parses bare array without skills key`() {
val raw = """[{"name": "x", "description": "d", "body": "b"}, {"name": "y"}]"""
val out = SkillMiningParser.parse(raw)
assertEquals(2, out.size)
assertEquals("x", out[0].name)
assertEquals("", out[1].body)
}
@Test
fun `unescapes newlines and quotes in body`() {
val raw = "{\"skills\": [{\"name\": \"n\", \"description\": \"\", \"body\": \"line1\\nline2\\n\\nwith \\\"quotes\\\" and backslash \\\\\\\"\"}]}"
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
val body = out[0].body
assertTrue(body.contains("line1\nline2"), "body: $body")
assertTrue(body.contains("\"quotes\""), "body: $body")
}
@Test
fun `empty skills array returns empty list`() {
val out = SkillMiningParser.parse("""{"skills": []}""")
assertTrue(out.isEmpty())
}
@Test
fun `model chatter with no JSON returns empty`() {
val out = SkillMiningParser.parse("Скилов не нашёл, всё чисто.")
assertTrue(out.isEmpty())
}
@Test
fun `truncated JSON returns empty`() {
val out = SkillMiningParser.parse("""{"skills": [{"name": "a", "description": "d", "body": """"")
assertTrue(out.isEmpty())
}
@Test
fun `skills without name are dropped`() {
val raw = """{"skills": [{"description": "no name"}, {"name": "ok"}]}"""
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("ok", out[0].name)
}
@Test
fun `nested braces inside strings do not break balance`() {
val raw = """{"skills": [{"name": "n", "description": "d", "body": "echo '{\"k\": 1}'"}]}"""
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("echo '{\"k\": 1}'", out[0].body)
}
}
@@ -0,0 +1,64 @@
package pw.binom.agentik.skill.mining
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 describeIsFlatOpenApiSchema() {
val json = tool.describe()
assertTrue("\"name\"" in json)
assertTrue(SkillReadTool.NAME in json)
assertTrue("parameters" in json)
assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" 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(""))
}
}