Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s
ci / JVM build + tests (push) Successful in 6m46s
This commit is contained in:
@@ -2,9 +2,8 @@
|
||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||
//
|
||||
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
|
||||
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
|
||||
// доступные JVM target'у.
|
||||
// KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
|
||||
// `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
|
||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||
|
||||
plugins {
|
||||
@@ -15,6 +14,14 @@ kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
@@ -24,16 +31,18 @@ kotlin {
|
||||
api(project(":context-api"))
|
||||
api(project(":skills"))
|
||||
api(libs.litert.api)
|
||||
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
|
||||
// jvm/native variant из общего артефакта).
|
||||
implementation(libs.kotlin.logging)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// mu.KotlinLogging — JVM-only, для SkillMiner'а
|
||||
implementation(libs.kotlin.logging)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.litert.tools.kotlinx.serialization)
|
||||
implementation(project(":memory-md"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class LlmReflector(
|
||||
private val llm: LiteLlm,
|
||||
val maxTurns: Int = 6,
|
||||
private val maxTokens: Int = 512,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
/**
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
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.IO,
|
||||
) {
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
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,127 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
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,71 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import pw.binom.agentik.llm.tools.ReflectionParser
|
||||
|
||||
class ReflectionParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses clean JSON`() {
|
||||
val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(4, p.score)
|
||||
assertEquals("ok", p.summary)
|
||||
assertEquals(listOf("a", "b"), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses JSON wrapped in json fences`() {
|
||||
val raw = "```json\n" +
|
||||
"{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" +
|
||||
"```"
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(3, p.score)
|
||||
assertEquals(listOf<String>(), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses JSON with leading and trailing text`() {
|
||||
val raw = "Вот мой ответ:\n" +
|
||||
"{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" +
|
||||
"Конец."
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(2, p.score)
|
||||
assertEquals(listOf("путаю", "медленно"), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts score as string`() {
|
||||
val raw = """{"score": "5", "summary": "ok", "weakSpots": []}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(5, p.score)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null on missing score`() {
|
||||
val raw = """{"summary": "x", "weakSpots": []}"""
|
||||
assertNull(ReflectionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null on invalid JSON`() {
|
||||
assertNull(ReflectionParser.parse("not even json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles escape sequences in weakSpots`() {
|
||||
// raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате
|
||||
val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
// парсер снимает один escape: \\\\ → \\
|
||||
assertEquals(listOf("path\\\\file"), p.weakSpots)
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package pw.binom.agentik.llm.tools.memory
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
import pw.binom.agentik.memory.MemorySource
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
import pw.binom.agentik.llm.tools.FakeLiteLlm
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
|
||||
import pw.binom.agentik.llm.tools.ReviewPrompts
|
||||
|
||||
class LlmMemoryReviewerTest {
|
||||
|
||||
/**
|
||||
* Минимальный in-memory store для тестов — реализует [MemoryStore],
|
||||
* хранит заметки в MutableList, поддерживает events flow.
|
||||
*/
|
||||
private class InMemoryStore : MemoryStore {
|
||||
private val notes = mutableMapOf<String, MemoryNote>()
|
||||
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
|
||||
|
||||
override suspend fun upsert(note: MemoryNote) {
|
||||
notes[note.id] = note
|
||||
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||
}
|
||||
|
||||
override suspend fun get(id: String): MemoryNote? = notes[id]
|
||||
override suspend fun list(
|
||||
category: MemoryCategory?,
|
||||
conversationId: String?,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): List<MemoryNote> = notes.values
|
||||
.filter { category == null || it.category == category }
|
||||
.filter { conversationId == null || it.conversationId == conversationId }
|
||||
.sortedByDescending { it.lastUsedAt }
|
||||
.drop(offset)
|
||||
.take(limit)
|
||||
|
||||
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> = emptyList()
|
||||
|
||||
override suspend fun delete(id: String): Boolean = notes.remove(id) != null
|
||||
|
||||
override suspend fun markUsed(id: String, at: Instant) {
|
||||
notes[id]?.let {
|
||||
notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun events(): kotlinx.coroutines.flow.Flow<MemoryStoreEvent> = _events
|
||||
|
||||
override fun close() {}
|
||||
|
||||
// Helper for tests to seed notes
|
||||
fun seed(note: MemoryNote) {
|
||||
notes[note.id] = note
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review parses save JSON and applies upsert`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}"""
|
||||
}
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(
|
||||
ReviewedTurn(
|
||||
userMessage = "Меня Саша зовут",
|
||||
assistantMessage = "Приятно познакомиться, Саша!",
|
||||
)
|
||||
)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||
|
||||
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||
assertEquals(1, applied.saved)
|
||||
|
||||
val all = store.list()
|
||||
assertEquals(1, all.size)
|
||||
assertEquals("Имя — Саша", all[0].content)
|
||||
assertEquals(MemorySource.AUTO_REVIEW, all[0].source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review applies delete decisions`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """{"save":[],"delete":["mem-stale-1"]}"""
|
||||
}
|
||||
val store = InMemoryStore().apply {
|
||||
seed(
|
||||
MemoryNote(
|
||||
id = "mem-stale-1",
|
||||
category = MemoryCategory.USER,
|
||||
content = "stale",
|
||||
createdAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
useCount = 0,
|
||||
source = MemorySource.AUTO_REVIEW,
|
||||
)
|
||||
)
|
||||
}
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(ReviewedTurn("удали это", "ок"))
|
||||
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||
assertEquals(0, applied.saved)
|
||||
assertEquals(1, applied.deleted)
|
||||
assertEquals(0, store.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review returns empty decision when LLM produces garbage`() = runTest {
|
||||
val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." }
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||
assertTrue(decision.toSave.isEmpty())
|
||||
assertTrue(decision.toDelete.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review handles empty LLM reply`() = runTest {
|
||||
val llm = FakeLiteLlm().apply { reply = "" }
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||
assertTrue(decision.toSave.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review creates conversation with review system prompt`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """{"save":[],"delete":[]}"""
|
||||
}
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
reviewer.review(ReviewedTurn("u", "a"))
|
||||
|
||||
assertNotNull(llm.lastConfig)
|
||||
assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reviewPreCompaction processes batch of turns`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """
|
||||
{"save":[
|
||||
{"category":"USER","content":"Работает в Яндексе"},
|
||||
{"category":"WORLD","content":"JVector — pure-Java ANN"}
|
||||
],"delete":[]}
|
||||
""".trimIndent()
|
||||
}
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val turns = listOf(
|
||||
pw.binom.agentik.memory.ConversationTurn(
|
||||
userMessage = "Я в Яндексе работаю",
|
||||
assistantMessage = "Круто!",
|
||||
),
|
||||
pw.binom.agentik.memory.ConversationTurn(
|
||||
userMessage = "А что за JVector?",
|
||||
assistantMessage = "ANN-библиотека на Java.",
|
||||
),
|
||||
)
|
||||
|
||||
val decision = reviewer.reviewPreCompaction(turns)
|
||||
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||
|
||||
assertEquals(2, applied.saved)
|
||||
assertEquals(2, store.list().size)
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package pw.binom.agentik.llm.tools.memory
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.agentik.llm.tools.ReviewDecisionParser
|
||||
|
||||
class ReviewDecisionParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses save array with USER category`() {
|
||||
val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||
assertEquals("Имя пользователя — Саша", decision.toSave[0].content)
|
||||
assertTrue(decision.toDelete.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses all three categories`() {
|
||||
val raw = """
|
||||
{"save":[
|
||||
{"category":"USER","content":"Работает в Яндексе"},
|
||||
{"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"},
|
||||
{"category":"PREFERENCE","content":"Отвечать кратко"}
|
||||
],"delete":[]}
|
||||
""".trimIndent()
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(3, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||
assertEquals(MemoryCategory.WORLD, decision.toSave[1].category)
|
||||
assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses delete array with ids`() {
|
||||
val raw = """{"save":[],"delete":["mem-123","mem-456"]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertTrue(decision.toSave.isEmpty())
|
||||
assertEquals(listOf("mem-123", "mem-456"), decision.toDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty decision on empty input`() {
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(""))
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty decision on non-JSON garbage`() {
|
||||
val raw = "Извини, я не могу помочь с этим."
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty decision on malformed JSON`() {
|
||||
val raw = """{"save":[{"category":"USER","content":"foo""" // truncated
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracts JSON from markdown code block`() {
|
||||
val raw = """
|
||||
Вот JSON:
|
||||
```json
|
||||
{"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]}
|
||||
```
|
||||
""".trimIndent()
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.WORLD, decision.toSave[0].category)
|
||||
assertEquals("SQLite 3.51", decision.toSave[0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips entries with unknown category`() {
|
||||
val raw = """{"save":[
|
||||
{"category":"USER","content":"valid"},
|
||||
{"category":"NOT_A_CATEGORY","content":"should be skipped"},
|
||||
{"category":"WORLD","content":"valid too"}
|
||||
],"delete":[]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(2, decision.toSave.size)
|
||||
assertEquals("valid", decision.toSave[0].content)
|
||||
assertEquals("valid too", decision.toSave[1].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips entries with blank content`() {
|
||||
val raw = """{"save":[
|
||||
{"category":"USER","content":""},
|
||||
{"category":"WORLD","content":" "}
|
||||
],"delete":[]}"""
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles escaped quotes in content`() {
|
||||
val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals("Сказал \"привет\"", decision.toSave[0].content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user