diff --git a/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProviderTest.kt b/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProviderTest.kt index c2070fb..90824e7 100644 --- a/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProviderTest.kt +++ b/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProviderTest.kt @@ -35,7 +35,7 @@ class SiglipEmbeddingProviderTest { @Test fun `missing model file fails with clear error`() { - val tmpDir = createTempDir(prefix = "no-model-") + val tmpDir = kotlin.io.path.createTempDirectory(prefix = "no-model-").toFile() val nonExistent = File(tmpDir, "does-not-exist.onnx") assertFailsWith { SiglipEmbeddingProvider( @@ -46,9 +46,6 @@ class SiglipEmbeddingProviderTest { } private inline fun assume(condition: Boolean, message: () -> String) { - if (!condition) { - println("SKIPPED: ${message()}") - return - } + org.junit.Assume.assumeTrue(message(), condition) } } diff --git a/standalone/README.md b/standalone/README.md index 35d961b..850f842 100644 --- a/standalone/README.md +++ b/standalone/README.md @@ -53,6 +53,8 @@ java -jar standalone/build/libs/standalone-all.jar | `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) | | `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend | | `AGENTIK_COMPRESSION_THRESHOLD` | `0.8` | Доля лимита, при которой запускается compaction | +| `AGENTIK_REFLECTION_INTERVAL` | `10` | Self-reflection: каждый N-й пользовательский ход агент оценивает себя (LiteLlm) и сохраняет рефлексию. `0` = выключено. | +| `AGENTIK_REFLECTION_TOP_K` | `3` | Сколько последних рефлексий подмешивать в system prompt как «слабые места». `0` = не подмешивать. | ### OpenAI backend @@ -178,6 +180,26 @@ export AGENTIK_COMPRESSION_THRESHOLD=0.7 # сжимаем раньше нового compaction не запускается (защита от зацикливания). Решение — поднять `OPENAI_CONTEXT_WINDOW` или понизить threshold. +## Self-reflection (Hermes-style «слабые места») + +Каждые `AGENTIK_REFLECTION_INTERVAL` пользовательских ходов (default 10) +запускается фоновая one-shot LLM-размышление: «оцени последние ходы, +поставь score 1..5, выдели слабые места». Результат сохраняется в таблицу +`reflection` SQLite и подмешивается в system prompt следующего хода как +«Твои слабые места за последнее время». + +Включено когда `AGENTIK_REFLECTION_INTERVAL > 0`. Требует LiteLlm +(on-device или OpenAI — что указан в `AGENTIK_LLM_BACKEND`). На каждый +reflection — один LiteLlm вызов (~1-3 сек для on-device, ~200-500мс для +OpenAI). Это происходит в фоне (`Dispatchers.IO`), основной диалог не +блокируется. + +Топ-K последних рефлексий загружается в `buildSystemPrompt` и выводится +как `## Self-reflection: твои слабые места за последнее время`. Агент +видит их в каждом следующем ходе и (теоретически) должен избегать +повторения. Используется как cheap "auto-improving prompt feedback" +без ручного переписывания system prompt. + ## Куратор памяти (Curator) Фоновая корутина (запускается автоматически, если `AGENTIK_MEMORY_DIR != off`): @@ -356,6 +378,7 @@ agentik standalone listening on http://localhost:8080 memory: /var/lib/agentik/memory (md-backend) compaction: enabled, threshold=0.8, window=128000 tokens curator: enabled (interval=1d, maxAge=90d) + reflection: enabled (interval=10, topK=3) ``` ## Остановка diff --git a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/Ids.kt b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/Ids.kt index d67fdb4..b9431bc 100644 --- a/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/Ids.kt +++ b/standalone/src/commonMain/kotlin/pw/binom/agentik/standalone/persistence/Ids.kt @@ -11,4 +11,5 @@ import kotlin.uuid.Uuid */ object Ids { fun new(prefix: String): String = "$prefix-${Uuid.random()}" + fun reflection(): String = new("refl") } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt index b21a692..bf49806 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -21,6 +21,7 @@ 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.LiteLlmContextCompactor +import pw.binom.agentik.standalone.agent.LlmReflector import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend @@ -155,6 +156,15 @@ fun main() { ) } ?: memorySystem?.reviewer + // Self-reflection: reflector работает только когда LLM доступен (нужен LiteLlm) + // и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt. + val reflector: pw.binom.agentik.standalone.agent.LlmReflector? = + if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null + val recentReflections: List = + if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking { + stores.reflections.listRecent(config.reflectionTopK) + } else emptyList() + val agent = ChatAgent( id = "agentik", stores = stores, @@ -169,6 +179,9 @@ fun main() { contextWindow = contextWindow, compressionThreshold = config.compressionThreshold, contextCompactor = contextCompactor, + recentReflections = recentReflections, + reflector = reflector, + reflectionInterval = config.reflectionInterval, ) val server = embeddedServer(CIO, port = config.port) { diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index 74e6d00..afcaea4 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -17,6 +17,7 @@ import pw.binom.agentik.skills.renderSystemPromptSection import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.persistence.ConversationRecord +import pw.binom.agentik.standalone.persistence.Reflection import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.litert.LiteLlm @@ -71,10 +72,24 @@ class ChatAgent( * Сжиматель контекста. Вызывается только при превышении [compressionThreshold]. */ private val contextCompactor: ContextCompactor? = null, + /** + * Self-reflection: список последних рефлексий, подмешиваемых в system prompt. + * Если `null` или пустой — секция не добавляется. + */ + private val recentReflections: List = emptyList(), + /** + * Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен. + */ + private val reflector: LlmReflector? = null, + /** + * Через сколько пользовательских ходов запускать рефлексию. `0` = выключено. + */ + private val reflectionInterval: Int = 0, ) : ProtoAgent, AutoCloseable { /** - * Системный промпт: (soul, если задан) → база → секция навыков → секция памяти. + * Системный промпт: (soul, если задан) → база → секция навыков → секция памяти + * → секция self-reflection (слабые места). * Именно он сидируется в working memory и передаётся в [ChatConversation]. */ private val systemPrompt: String = buildSystemPrompt( @@ -82,6 +97,7 @@ class ChatAgent( skills = skills, memoryEnabled = memoryStore != null, soulBody = soulBody, + reflections = recentReflections, ) /** @@ -145,6 +161,9 @@ class ChatAgent( contextWindow = contextWindow, compressionThreshold = compressionThreshold, contextCompactor = contextCompactor, + reflectionStore = stores.reflections, + reflector = reflector, + reflectionInterval = reflectionInterval, ) runBlocking { liveLock.withLock { live[conv.id] = conv } @@ -189,6 +208,9 @@ class ChatAgent( contextWindow = contextWindow, compressionThreshold = compressionThreshold, contextCompactor = contextCompactor, + reflectionStore = stores.reflections, + reflector = reflector, + reflectionInterval = reflectionInterval, ) override fun close() { @@ -212,11 +234,50 @@ class ChatAgent( * Собирает итоговый системный промпт: базовый текст + секция навыков * (только если скилы есть). Пустая секция → базовый промпт без изменений. */ -internal fun buildSystemPrompt(base: String, skills: SkillCatalog, memoryEnabled: Boolean, soulBody: String? = null): String { +internal fun buildSystemPrompt( + base: String, + skills: SkillCatalog, + memoryEnabled: Boolean, + soulBody: String? = null, + reflections: List = emptyList(), +): String { val trimmedBase = base.trimEnd() val skillsSection = skills.renderSystemPromptSection() val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection val withMemory = if (memoryEnabled) withSkills + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else withSkills + val withReflections = if (reflections.isNotEmpty()) { + withMemory + "\n\n" + renderReflectionsSection(reflections) + } else { + withMemory + } val trimmedSoul = soulBody?.trim() - return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withMemory else withMemory + return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withReflections else withReflections +} + +/** + * Форматирует top-N рефлексий в секцию system prompt. + * + * Формат: + * ``` + * ## Self-reflection: твои слабые места + * + * - [score=2/5, 2026-09-15 12:30] — медленно отвечаю на вопросы про X, путаю A и B + * - [score=3/5, ...] + * ``` + */ +internal fun renderReflectionsSection(reflections: List): String = buildString { + appendLine("## Self-reflection: твои слабые места за последнее время") + appendLine() + appendLine("Не повторяй эти ошибки. Если чувствуешь, что ответ снова попадает") + appendLine("в похожий паттерн — остановись и пересмотри.") + appendLine() + for (r in reflections) { + append("- [score=${r.score}/5, ${r.createdAt}] ") + if (r.weakSpots.isNotEmpty()) { + append(r.weakSpots.joinToString("; ")) + } else { + append(r.summary.take(80).ifBlank { "(без комментариев)" }) + } + appendLine() + } } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt index 087c98d..1e0b3dc 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import pw.binom.agentik.memory.MemoryNote @@ -107,6 +108,18 @@ class ChatConversation( * продолжит работать как раньше). */ private val contextCompactor: ContextCompactor? = null, + /** + * Хранилище self-reflection. `null` = reflection отключён. + */ + private val reflectionStore: pw.binom.agentik.standalone.persistence.ReflectionStore? = null, + /** + * Исполнитель рефлексии (one-shot LiteLlm). `null` = reflection отключён. + */ + private val reflector: LlmReflector? = null, + /** + * Через сколько пользовательских ходов запускать рефлексию. `0` = выключено. + */ + private val reflectionInterval: Int = 0, ) : ProtoConversation, AutoCloseable { private var record: ConversationRecord = record @@ -318,6 +331,7 @@ class ChatConversation( } scheduleReview(userRecord, assistantContent) + scheduleReflection(userRecord, assistantContent) emitEvent(ProtoEvent.End(date = assistantAt)) } @@ -557,6 +571,58 @@ class ChatConversation( } } + /** + * Self-reflection триггер. Каждые [reflectionInterval] пользовательских ходов + * запускает фоновый [LlmReflector.reflect], сохраняет результат в + * [reflectionStore]. Не блокирует turn. + * + * Для temp-бесед и без reflector'а — no-op. + */ + private fun scheduleReflection( + userRecord: MessageRecord.UserMessage, + assistantContent: List, + ) { + if (reflectionInterval <= 0) return + val reflector = reflector ?: return + val store = reflectionStore ?: return + if (record.isTemporal) return + val userText = userRecord.content.filterIsInstance() + .joinToString("\n") { it.body } + val assistantText = assistantContent.filterIsInstance() + .joinToString("\n") { it.body } + if (userText.isBlank() || assistantText.isBlank()) return + // Считаем пользовательские ходы в working memory. + val userTurnCount = countUserTurnsBlocking() + if (userTurnCount % reflectionInterval != 0) return + val convId = id + scope.launch { + try { + val turns = listOf( + pw.binom.agentik.memory.ConversationTurn( + userMessage = userText, + assistantMessage = assistantText, + ) + ) + val reflection = reflector.reflect(turns) ?: return@launch + val stamped = reflection.copy(conversationId = convId) + runCatching { store.insert(stamped) } + .onFailure { log.warn(it) { "reflection insert failed: ${it.message}" } } + log.info { "self-reflection score=${stamped.score}/5 conv=$convId spots=${stamped.weakSpots.size}" } + } catch (e: Throwable) { + log.warn(e) { "reflection failed for $convId: ${e.message}" } + } + } + } + + /** Считает user-ходы в текущем working memory (используется для триггера reflection). */ + private fun countUserTurnsBlocking(): Int = runBlocking { + var count = 0 + for (row in workingMemory.list(id)) { + if (row.entry is WorkingMemoryEntry.User) count++ + } + count + } + /** * Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult, * пишем в audit + working memory, подаём результат в LiteConversation. diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/LlmReflector.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/LlmReflector.kt new file mode 100644 index 0000000..5bedff1 --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/LlmReflector.kt @@ -0,0 +1,72 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import pw.binom.agentik.memory.ConversationTurn +import pw.binom.litert.LiteConversationConfig +import pw.binom.litert.LiteLlm +import pw.binom.agentik.standalone.persistence.Ids +import pw.binom.agentik.standalone.persistence.Reflection +import kotlin.time.Clock + +/** + * One-shot LLM-размышление о качестве последних ходов диалога. + * + * Использует structured-output JSON prompt (так же как [LlmMemoryReviewer]): + * модель возвращает `{ score: 1-5, summary: "...", weakSpots: ["...", "..."] }`, + * парсер [ReflectionParser] возвращает [Reflection]. + * + * Триггер: каждые N ходов (AGENTIK_REFLECTION_INTERVAL, default 10). + * Не блокирует основной диалог — вызывается в фоне на [dispatcher]. + * + * @param llm LLM-бэкенд + * @param maxTurns сколько последних ходов передавать модели (default 6) + * @param maxTokens размер ответа LLM (default 512) + * @param dispatcher диспетчер для блокирующего LLM-вызова + * @param clock для генерации id/timestamp + */ +class LlmReflector( + private val llm: LiteLlm, + private val maxTurns: Int = 6, + private val maxTokens: Int = 512, + private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, + private val clock: Clock = Clock.System, +) { + /** + * Reflect по последним [turns]. Возвращает [Reflection] или null если + * парсер не смог распарсить (например модель вернула полную ерунду). + * + * Вызов блокирующий: ~1-3 сек на CPU для on-device LiteRT-LM, ~200-500мс + * для OpenAI. Поэтому в проде всегда вызывается из background scope. + */ + suspend fun reflect(turns: List): Reflection? = withContext(dispatcher) { + require(turns.isNotEmpty()) { "need at least one turn to reflect" } + val conversation = llm.createConversation( + config = LiteConversationConfig( + systemInstruction = ReflectionPrompts.SYSTEM_PROMPT, + initialMessages = emptyList(), + tools = emptyList(), + ) + ) + try { + val userPrompt = ReflectionPrompts.buildUserPrompt( + turns = turns.takeLast(maxTurns), + maxTurns = maxTurns, + ) + val raw = conversation.send(userPrompt) + val parsed = ReflectionParser.parse(raw) + ?: return@withContext null + Reflection( + id = Ids.reflection(), + conversationId = null, // будет проставлен caller'ом ChatConversation + createdAt = clock.now(), + turnsAnalyzed = turns.size, + score = parsed.score.coerceIn(1, 5), + summary = parsed.summary, + weakSpots = parsed.weakSpots, + ) + } finally { + conversation.close() + } + } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionParser.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionParser.kt new file mode 100644 index 0000000..1abf967 --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionParser.kt @@ -0,0 +1,117 @@ +package pw.binom.agentik.standalone.agent + +/** + * Минимальный парсер JSON-ответа от [LlmReflector]. + * + * Ожидаемая форма: + * ``` + * {"score": 4, "summary": "...", "weakSpots": ["...", "..."]} + * ``` + * + * Допуски: + * - Модель может обернуть ответ в ```json ... ``` fences — обрезаем. + * - Может быть лидирующий/завершающий текст до/после JSON — находим первую + * `{` и парсим баланс скобок до парной `}`. + * - `score` может быть числом или строкой ("4") — оба варианта ок. + * - `weakSpots` может быть пустым массивом. + * - Любые невалидные символы → null (defensive: лучше пропустить рефлексию, + * чем уронить agent loop). + */ +object ReflectionParser { + + data class Parsed(val score: Int, val summary: String, val weakSpots: List) + + fun parse(raw: String): Parsed? { + val json = extractJsonObject(raw) ?: return null + val score = extractIntField(json, "score") ?: return null + val summary = extractStringField(json, "summary") ?: "" + val weakSpots = extractStringArrayField(json, "weakSpots") ?: emptyList() + return Parsed(score = score, summary = summary, weakSpots = weakSpots) + } + + /** + * Извлекает JSON-объект из произвольного текста: обрезает ``` fences, + * пропускает префикс/суффикс, ищет первую `{` и парную `}` по балансу скобок. + */ + internal fun extractJsonObject(raw: String): String? { + var s = raw.trim() + // Strip ```json / ``` fences + if (s.startsWith("```")) { + val firstNewline = s.indexOf('\n') + if (firstNewline > 0) s = s.substring(firstNewline + 1) + if (s.endsWith("```")) s = s.substring(0, s.length - 3) + } + val open = s.indexOf('{') + if (open < 0) return null + var depth = 0 + var i = open + var inString = false + var escape = false + while (i < s.length) { + val c = s[i] + if (escape) { escape = false; i++; continue } + if (c == '\\' && inString) { escape = true; i++; continue } + if (c == '"') { inString = !inString; i++; continue } + if (!inString) { + when (c) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) return s.substring(open, i + 1) + } + } + } + i++ + } + return null + } + + /** Достаёт числовое поле из JSON-объекта: "score": 4 или "score": "4". */ + internal fun extractIntField(json: String, name: String): Int? { + val re = Regex(""""$name"\s*:\s*(?:(\d+)|"(\d+)")""") + val match = re.find(json) ?: return null + val n = match.groupValues[1].ifEmpty { match.groupValues[2] } + return n.toIntOrNull() + } + + /** Достаёт строковое поле: "summary": "..." с `\"` и `\\` escape. */ + internal fun extractStringField(json: String, name: String): String? { + val re = Regex(""""$name"\s*:\s*"((?:\\.|[^"\\])*)"""") + val match = re.find(json) ?: return null + return unescape(match.groupValues[1]) + } + + /** Достаёт массив строк: "weakSpots": ["a", "b"]. Возвращает пустой список если поле отсутствует. */ + internal fun extractStringArrayField(json: String, name: String): List? { + val re = Regex(""""$name"\s*:\s*\[([^\]]*)]""") + val match = re.find(json) ?: return null + val inner = match.groupValues[1] + if (inner.isBlank()) return emptyList() + val out = mutableListOf() + val itemRe = Regex("""\"((?:\\.|[^\"\\])*)\"""") + for (m in itemRe.findAll(inner)) { + out.add(unescape(m.groupValues[1])) + } + return out + } + + private fun unescape(s: String): String = buildString { + var i = 0 + while (i < s.length) { + val c = s[i] + if (c == '\\' && i + 1 < s.length) { + when (s[i + 1]) { + '"' -> append('"') + '\\' -> append('\\') + 'n' -> append('\n') + 't' -> append('\t') + else -> append(s[i + 1]) + } + i += 2 + } else { + append(c) + i++ + } + } + } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionPrompts.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionPrompts.kt new file mode 100644 index 0000000..42c9d54 --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionPrompts.kt @@ -0,0 +1,61 @@ +package pw.binom.agentik.standalone.agent + +import pw.binom.agentik.memory.ConversationTurn + +/** + * Промпты для [LlmReflector] — one-shot self-reflection. + * + * Стиль: structured-output (модель отвечает JSON, не зовёт тулзы). + * Это та же техника, что в `LlmMemoryReviewer`: on-device LiteRT-LM + * плохо работает с tool-calling, но стабильно отвечает на JSON-prompt + * при явном `respond with JSON` указании. + */ +object ReflectionPrompts { + + /** + * System-prompt для размышления. + * Русский — потому что весь остальной agentik тоже ru-flavored + * (review-prompt, MemorySystemGuidance и т.п.). + */ + const val SYSTEM_PROMPT = """Ты — критический аналитик собственной работы ассистента. +Тебе дадут последние ходы диалога: пары user/assistant сообщений. +Оцени, насколько хорошо ассистент справился с задачами пользователя. + +Шкала score (одно целое число): + 1 — ассистент путался, галлюцинировал, не отвечал на вопрос, игнорировал контекст. + 2 — были заметные проблемы (неточные факты, странные ответы). + 3 — нормальная работа, ничего особенного. + 4 — хорошая работа, помог пользователю, был полезным. + 5 — отличная работа: точный, полезный, уместный. + +weakSpots — это массив КОРОТКИХ строк (1-3 слова каждая), конкретные слабые +места, которые заметил. Примеры: + - "медленно отвечаю на вопросы про X" + - "путаю A и B" + - "слишком длинные ответы на простые вопросы" + - "не помню контекст разговора" + +summary — свободный markdown-комментарий (1-3 предложения): что именно +было хорошо, что плохо, что улучшить. + +ВАЖНО: ответь СТРОГО JSON объектом: +{"score": <1-5>, "summary": "", "weakSpots": ["...", "..."]} + +Никаких пояснений до или после JSON. Только валидный JSON.""" + + /** + * User-prompt: последние ходы диалога. Каждый ход — пара + * `[user] text` / `[assistant] text`. Старые ходы обрезаются до [maxTurns]. + */ + fun buildUserPrompt(turns: List, maxTurns: Int): String = buildString { + appendLine("Последние ${turns.size} из $maxTurns ходов диалога:") + appendLine() + for ((idx, turn) in turns.withIndex()) { + appendLine("--- Ход ${idx + 1} ---") + appendLine("[user]: ${turn.userMessage}") + appendLine("[assistant]: ${turn.assistantMessage}") + appendLine() + } + append("Оцени по шкале и верни JSON.") + } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt index ad1399f..484e198 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt @@ -85,6 +85,17 @@ data class AgentikConfig( * [embeddingBackend] = SIGLIP. */ val embeddingTokenizerPath: String? = null, + /** + * Через сколько пользовательских ходов запускать self-reflection. + * `0` или `null` — отключает reflection. Default: 10. + * См. [LlmReflector]. + */ + val reflectionInterval: Int = DEFAULT_REFLECTION_INTERVAL, + /** + * Сколько последних reflection-записей подмешивать в system prompt. + * Default: 3. `0` — не подмешивать. + */ + val reflectionTopK: Int = DEFAULT_REFLECTION_TOP_K, ) { /** Бэкенд долговременной памяти. */ @Serializable @@ -100,6 +111,8 @@ data class AgentikConfig( const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8 const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small" const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536 + const val DEFAULT_REFLECTION_INTERVAL: Int = 10 + const val DEFAULT_REFLECTION_TOP_K: Int = 3 /** * Читает конфигурацию из переменных среды. @@ -128,6 +141,10 @@ data class AgentikConfig( } ?: EmbeddingBackend.HTTP, embeddingModelPath = env("AGENTIK_EMBEDDING_MODEL_PATH")?.takeIf { it.isNotBlank() }, embeddingTokenizerPath = env("AGENTIK_EMBEDDING_TOKENIZER_PATH")?.takeIf { it.isNotBlank() }, + reflectionInterval = env("AGENTIK_REFLECTION_INTERVAL")?.toIntOrNull() + ?.coerceIn(0, 1000) ?: DEFAULT_REFLECTION_INTERVAL, + reflectionTopK = env("AGENTIK_REFLECTION_TOP_K")?.toIntOrNull() + ?.coerceIn(0, 20) ?: DEFAULT_REFLECTION_TOP_K, ) } } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/persistence/ReflectionStore.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/persistence/ReflectionStore.kt new file mode 100644 index 0000000..5293e9d --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/persistence/ReflectionStore.kt @@ -0,0 +1,182 @@ +package pw.binom.agentik.standalone.persistence + +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import mu.KotlinLogging +import pw.binom.agentik.standalone.persistence.sqlite.AgentikDatabase +import pw.binom.agentik.standalone.persistence.sqlite.Reflection as DbReflection +import pw.binom.agentik.standalone.persistence.sqlite.ReflectionQueries + +private val log = KotlinLogging.logger {} + +/** + * Self-reflection запись — что агент "думает" о качестве своих последних ходов. + * + * - [score]: 1..5 (самооценка качества) + * - [weakSpots]: конкретные слабые места ("медленно ищу в Y", "путаю A и B") + * - [summary]: свободный комментарий в markdown (что заметил, что улучшить) + * + * Источник: one-shot LLM-размышление после каждых N ходов (см. AGENTIK_REFLECTION_INTERVAL). + * Используется в `ChatAgent.buildSystemPrompt` как "слабые места за последнее время". + */ +data class Reflection( + val id: String, + val conversationId: String?, + val createdAt: Instant, + val turnsAnalyzed: Int, + val score: Int, + val summary: String, + val weakSpots: List, +) + +/** + * Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу. + * + * Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup) + * или архивируются через [Curator]-подобный процесс, но не редактируются. + */ +interface ReflectionStore : AutoCloseable { + suspend fun insert(reflection: Reflection) + suspend fun get(id: String): Reflection? + /** Самые свежие рефлексии (по всему агенту). */ + suspend fun listRecent(limit: Int = 10): List + /** Рефлексии для конкретного диалога. */ + suspend fun listForConversation(conversationId: String, limit: Int = 10): List + suspend fun deleteOlderThan(cutoff: Instant) + suspend fun count(): Int + + /** Стрим новых рефлексий для подписчиков (для UI в будущем). */ + fun events(): Flow = kotlinx.coroutines.flow.emptyFlow() + + override fun close() +} + +sealed interface ReflectionEvent { + data class Created(val reflection: Reflection) : ReflectionEvent +} + +/** + * SQLDelight-реализация [ReflectionStore]. + * + * `weakSpots` хранится как JSON-массив (маленький массив строк, ~ десяток); + * парсим минимальным ручным сканером, чтобы не тащить kotlinx-serialization + * в этот модуль (он уже есть в commonMain через standalone — но не хочется + * парсить JSON ради одного массива строк). + */ +class SqliteReflectionStore( + private val db: AgentikDatabase, + private val clock: Clock = Clock.System, +) : ReflectionStore { + private val queries: ReflectionQueries get() = db.reflectionQueries + private val ev = MutableSharedFlow(extraBufferCapacity = 16) + + override suspend fun insert(reflection: Reflection) { + log.debug { "insert reflection id=${reflection.id} conv=${reflection.conversationId} score=${reflection.score}" } + queries.insert( + id = reflection.id, + conversation_id = reflection.conversationId, + created_at = reflection.createdAt.toEpochMilliseconds(), + turns_analyzed = reflection.turnsAnalyzed.toLong(), + score = reflection.score.toLong(), + summary = reflection.summary, + weak_spots_json = encodeStringArray(reflection.weakSpots), + ) + ev.tryEmit(ReflectionEvent.Created(reflection)) + } + + override suspend fun get(id: String): Reflection? = + queries.getById(id).executeAsOneOrNull()?.toDomain() + + override suspend fun listRecent(limit: Int): List = + queries.listRecent(limit.toLong()).executeAsList().map { it.toDomain() } + + override suspend fun listForConversation(conversationId: String, limit: Int): List = + queries.listForConversation(conversationId, limit.toLong()).executeAsList().map { it.toDomain() } + + override suspend fun deleteOlderThan(cutoff: Instant) { + val n = queries.deleteOlderThan(cutoff.toEpochMilliseconds()).value + if (n > 0) log.info { "deleted $n reflections older than $cutoff" } + } + + override suspend fun count(): Int = queries.count().executeAsOne().toInt() + + override fun events(): Flow = ev.asSharedFlow() + + override fun close() { + // no-op: lifecycle owned by AgentikDatabase / SqliteStores + } + + private fun DbReflection.toDomain(): Reflection = Reflection( + id = id, + conversationId = conversation_id, + createdAt = Instant.fromEpochMilliseconds(created_at), + turnsAnalyzed = turns_analyzed.toInt(), + score = score.toInt(), + summary = summary, + weakSpots = decodeStringArray(weak_spots_json), + ) +} + +/** + * Простой JSON-encode списка строк как `"[\"a\",\"b\"]"`. + * + * Hand-rolled чтобы не тащить kotlinx-serialization ради одного типа; строки + * экранируются только от `"` и `\` (этого достаточно для текста заметок). + */ +internal fun encodeStringArray(items: List): String = buildString { + append('[') + items.forEachIndexed { i, s -> + if (i > 0) append(',') + append('"') + for (c in s) { + when (c) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + else -> append(c) + } + } + append('"') + } + append(']') +} + +/** + * Минимальный JSON-парсер для массива строк: ожидает форму `[...]`, + * внутри — строки в `"..."` с `\"`/`\\` escape. Любые невалидные символы + * возвращаются как пустой список — лучше пустая рефлексия, чем exception. + */ +internal fun decodeStringArray(raw: String): List { + val s = raw.trim() + if (!s.startsWith('[') || !s.endsWith(']')) return emptyList() + val inner = s.substring(1, s.length - 1) + if (inner.isBlank()) return emptyList() + val out = mutableListOf() + var i = 0 + while (i < inner.length) { + while (i < inner.length && inner[i].isWhitespace() || inner[i] == ',') i++ + if (i >= inner.length) break + if (inner[i] != '"') return emptyList() + i++ + val sb = StringBuilder() + while (i < inner.length && inner[i] != '"') { + if (inner[i] == '\\' && i + 1 < inner.length) { + when (inner[i + 1]) { + '"' -> sb.append('"') + '\\' -> sb.append('\\') + else -> sb.append(inner[i + 1]) + } + i += 2 + } else { + sb.append(inner[i]) + i++ + } + } + if (i >= inner.length) return emptyList() // unterminated + i++ // closing " + out.add(sb.toString()) + } + return out +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/persistence/sqlite/SqliteStores.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/persistence/sqlite/SqliteStores.kt index 814a059..ef7b3fc 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/persistence/sqlite/SqliteStores.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/persistence/sqlite/SqliteStores.kt @@ -5,6 +5,8 @@ import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import pw.binom.agentik.standalone.persistence.ConversationStore import pw.binom.agentik.standalone.persistence.MessageStore +import pw.binom.agentik.standalone.persistence.ReflectionStore +import pw.binom.agentik.standalone.persistence.SqliteReflectionStore import pw.binom.agentik.standalone.persistence.WorkingMemoryStore /** @@ -16,12 +18,14 @@ class SqliteStores private constructor( val conversations: ConversationStore, val messages: MessageStore, val workingMemory: WorkingMemoryStore, + val reflections: ReflectionStore, ) : AutoCloseable { override fun close() { conversations.close() messages.close() workingMemory.close() + reflections.close() driver.close() } @@ -37,6 +41,7 @@ class SqliteStores private constructor( conversations = SqliteConversationStore(db), messages = SqliteMessageStore(db), workingMemory = SqliteWorkingMemoryStore(db), + reflections = SqliteReflectionStore(db), ) } @@ -50,12 +55,14 @@ class SqliteStores private constructor( conversations = SqliteConversationStore(db), messages = SqliteMessageStore(db), workingMemory = SqliteWorkingMemoryStore(db), + reflections = SqliteReflectionStore(db), ) } private fun createSchema(driver: SqlDriver) { - // Если таблица `conversation` уже есть — БД уже инициализирована, - // просто пропускаем create (иначе CREATE TABLE упадёт на дубликате). + // Если таблица `conversation` уже есть — БД уже инициализирована. + // Всё равно прогоняем additive-миграции (см. runMigrations), потому что + // в новых версиях могли появиться таблицы, которых нет в этой БД. val existing = driver.executeQuery( identifier = null, sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation'", @@ -66,8 +73,42 @@ class SqliteStores private constructor( }, parameters = 0, ).value - if (existing != null) return - AgentikDatabase.Schema.create(driver) + if (existing == null) { + // Свежая БД — пусть SQLDelight создаст всё сразу. + AgentikDatabase.Schema.create(driver) + return + } + // БД уже есть — прогоняем миграции для новых таблиц. + runMigrations(driver) + } + + /** + * Additive-миграции для таблиц, добавленных после первого релиза. + * Каждая миграция — `CREATE TABLE IF NOT EXISTS ...`, идемпотентна. + * Без Schema.version (SQLDelight migrations .sqm файлов) — для простой + * additive-семантики это OK: удалять/переименовывать таблицы мы + * всё равно пока не планируем. + */ + private fun runMigrations(driver: SqlDriver) { + val migrations = listOf( + // v2: self-reflection (см. Reflection.sq) + """ + CREATE TABLE IF NOT EXISTS reflection ( + id TEXT NOT NULL PRIMARY KEY, + conversation_id TEXT, + created_at INTEGER NOT NULL, + turns_analyzed INTEGER NOT NULL, + score INTEGER NOT NULL, + summary TEXT NOT NULL, + weak_spots_json TEXT NOT NULL DEFAULT '[]' + ); + CREATE INDEX IF NOT EXISTS idx_reflection_created ON reflection(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_reflection_conv ON reflection(conversation_id, created_at DESC); + """.trimIndent(), + ) + for (sql in migrations) { + driver.execute(null, sql, 0) + } } } } diff --git a/standalone/src/jvmMain/sqldelight/pw/binom/agentik/standalone/persistence/sqlite/Reflection.sq b/standalone/src/jvmMain/sqldelight/pw/binom/agentik/standalone/persistence/sqlite/Reflection.sq new file mode 100644 index 0000000..8ff838b --- /dev/null +++ b/standalone/src/jvmMain/sqldelight/pw/binom/agentik/standalone/persistence/sqlite/Reflection.sq @@ -0,0 +1,43 @@ +-- Self-reflection log: что агент "думает" о качестве своих последних ответов. +-- Каждая запись — это one-shot LLM-размышление после N ходов (см. AGENTIK_REFLECTION_INTERVAL). +-- Используется в buildSystemPrompt как "слабые места" (top-N последних). + +CREATE TABLE reflection ( + id TEXT NOT NULL PRIMARY KEY, + conversation_id TEXT, -- может быть NULL для глобальных рефлексий + created_at INTEGER NOT NULL, -- epoch millis + turns_analyzed INTEGER NOT NULL, -- сколько ходов оценили + score INTEGER NOT NULL, -- 1..5 (самооценка качества) + summary TEXT NOT NULL, -- краткое резюме (markdown) + weak_spots_json TEXT NOT NULL DEFAULT '[]' -- JSON-массив строк: ["медленно отвечаю на X", ...] +); + +CREATE INDEX idx_reflection_created ON reflection(created_at DESC); +CREATE INDEX idx_reflection_conv ON reflection(conversation_id, created_at DESC); + +insert: +INSERT INTO reflection (id, conversation_id, created_at, turns_analyzed, score, summary, weak_spots_json) +VALUES (?, ?, ?, ?, ?, ?, ?); + +getById: +SELECT * FROM reflection WHERE id = ?; + +-- Top-N последних рефлексий для всего агента (для system prompt). +listRecent: +SELECT * FROM reflection +ORDER BY created_at DESC +LIMIT :limit; + +-- Top-N рефлексий для конкретного диалога. +listForConversation: +SELECT * FROM reflection +WHERE conversation_id = :convId +ORDER BY created_at DESC +LIMIT :limit; + +deleteOlderThan: +DELETE FROM reflection +WHERE created_at < :cutoffEpochMillis; + +count: +SELECT COUNT(*) FROM reflection; diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt new file mode 100644 index 0000000..9ef53b2 --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentReflectionTest.kt @@ -0,0 +1,74 @@ +package pw.binom.agentik.standalone.agent + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant +import pw.binom.agentik.skills.SkillCatalog +import pw.binom.agentik.standalone.persistence.Reflection + +class ChatAgentReflectionTest { + + @Test + fun `buildSystemPrompt omits section when reflections empty`() { + val prompt = buildSystemPrompt( + base = "base", + skills = SkillCatalog.EMPTY, + memoryEnabled = false, + soulBody = null, + reflections = emptyList(), + ) + assertFalse(prompt.contains("Self-reflection")) + assertFalse(prompt.contains("слабые места")) + } + + @Test + fun `buildSystemPrompt includes section with weak spots when reflections non-empty`() { + val r = Reflection( + id = "r1", + conversationId = null, + createdAt = Instant.parse("2026-09-15T12:00:00Z"), + turnsAnalyzed = 10, + score = 2, + summary = "плохо", + weakSpots = listOf("медленно отвечаю на X", "путаю A и B"), + ) + val prompt = buildSystemPrompt( + base = "base", + skills = SkillCatalog.EMPTY, + memoryEnabled = false, + soulBody = null, + reflections = listOf(r), + ) + assertTrue(prompt.contains("Self-reflection")) + assertTrue(prompt.contains("слабые места")) + assertTrue(prompt.contains("медленно отвечаю на X")) + assertTrue(prompt.contains("путаю A и B")) + assertTrue(prompt.contains("score=2/5")) + } + + @Test + fun `buildSystemPrompt places reflection section after memory and before soul prepend`() { + val r = Reflection( + id = "r1", + conversationId = null, + createdAt = Instant.parse("2026-09-15T12:00:00Z"), + turnsAnalyzed = 5, + score = 4, + summary = "ok", + weakSpots = listOf("minor issue"), + ) + val prompt = buildSystemPrompt( + base = "base", + skills = SkillCatalog.EMPTY, + memoryEnabled = true, + soulBody = "I am a soul", + reflections = listOf(r), + ) + // soul первый, reflection последняя + val soulIdx = prompt.indexOf("I am a soul") + val reflIdx = prompt.indexOf("Self-reflection") + assertTrue(soulIdx >= 0 && reflIdx >= 0) + assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection") + } +} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt new file mode 100644 index 0000000..8c934a2 --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt @@ -0,0 +1,70 @@ +package pw.binom.agentik.standalone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +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(), 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) + } +} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/ReflectionStoreTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/ReflectionStoreTest.kt new file mode 100644 index 0000000..6db760a --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/ReflectionStoreTest.kt @@ -0,0 +1,123 @@ +package pw.binom.agentik.standalone.persistence + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.time.Instant +import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores + +class ReflectionStoreTest { + + @Test + fun `insert and get roundtrip preserves all fields`() { + val stores = SqliteStores.inMemory() + try { + val store = stores.reflections + val r = sample(id = "r1", createdAt = Instant.parse("2026-09-15T12:00:00Z")) + kotlinx.coroutines.runBlocking { store.insert(r) } + + val loaded = kotlinx.coroutines.runBlocking { store.get("r1") } + assertNotNull(loaded) + assertEquals("r1", loaded.id) + assertEquals("conv-1", loaded.conversationId) + assertEquals(Instant.parse("2026-09-15T12:00:00Z"), loaded.createdAt) + assertEquals(5, loaded.turnsAnalyzed) + assertEquals(4, loaded.score) + assertEquals("норм", loaded.summary) + assertEquals(listOf("медленно", "путаю"), loaded.weakSpots) + } finally { stores.close() } + } + + @Test + fun `listRecent returns newest first with limit`() { + val stores = SqliteStores.inMemory() + try { + val at = Instant.parse("2026-09-15T12:00:00Z") + repeat(5) { i -> + kotlinx.coroutines.runBlocking { + stores.reflections.insert(sample(id = "r$i", createdAt = at.plus(kotlin.time.Duration.parse("${i}s")))) + } + } + val top3 = kotlinx.coroutines.runBlocking { stores.reflections.listRecent(limit = 3) } + assertEquals(3, top3.size) + assertEquals(listOf("r4", "r3", "r2"), top3.map { it.id }) + } finally { stores.close() } + } + + @Test + fun `listForConversation filters by convId`() { + val stores = SqliteStores.inMemory() + try { + val at = Instant.parse("2026-09-15T12:00:00Z") + kotlinx.coroutines.runBlocking { + stores.reflections.insert(sample(id = "a", createdAt = at, convId = "conv-A")) + stores.reflections.insert(sample(id = "b", createdAt = at, convId = "conv-B")) + stores.reflections.insert(sample(id = "c", createdAt = at, convId = "conv-A")) + } + val onlyA = kotlinx.coroutines.runBlocking { stores.reflections.listForConversation("conv-A", limit = 10) } + assertEquals(2, onlyA.size) + assertEquals(setOf("a", "c"), onlyA.map { it.id }.toSet()) + } finally { stores.close() } + } + + @Test + fun `deleteOlderThan removes only old records`() { + val stores = SqliteStores.inMemory() + try { + val t0 = Instant.parse("2026-09-15T12:00:00Z") + kotlinx.coroutines.runBlocking { + stores.reflections.insert(sample(id = "old", createdAt = t0)) + stores.reflections.insert(sample(id = "new", createdAt = t0.plus(kotlin.time.Duration.parse("1h")))) + } + val cutoff = t0.plus(kotlin.time.Duration.parse("1m")) + kotlinx.coroutines.runBlocking { stores.reflections.deleteOlderThan(cutoff) } + // "old" до cutoff — удаляется; "new" после cutoff — остаётся. + assertNull(kotlinx.coroutines.runBlocking { stores.reflections.get("old") }) + assertNotNull(kotlinx.coroutines.runBlocking { stores.reflections.get("new") }) + } finally { stores.close() } + } + + @Test + fun `count returns total records`() { + val stores = SqliteStores.inMemory() + try { + assertEquals(0, kotlinx.coroutines.runBlocking { stores.reflections.count() }) + repeat(3) { i -> + kotlinx.coroutines.runBlocking { + stores.reflections.insert(sample(id = "r$i", createdAt = Instant.parse("2026-09-15T12:00:00Z"))) + } + } + assertEquals(3, kotlinx.coroutines.runBlocking { stores.reflections.count() }) + } finally { stores.close() } + } + + @Test + fun `encodeStringArray handles quotes and backslashes`() { + val encoded = encodeStringArray(listOf("a\"b", "c\\d", "plain")) + val decoded = decodeStringArray(encoded) + assertEquals(listOf("a\"b", "c\\d", "plain"), decoded) + } + + @Test + fun `decodeStringArray handles empty and invalid`() { + assertEquals(emptyList(), decodeStringArray("[]")) + assertEquals(emptyList(), decodeStringArray("")) + assertEquals(emptyList(), decodeStringArray("not json")) + assertEquals(emptyList(), decodeStringArray("[unterminated")) + } + + private fun sample( + id: String, + createdAt: Instant, + convId: String = "conv-1", + ): Reflection = Reflection( + id = id, + conversationId = convId, + createdAt = createdAt, + turnsAnalyzed = 5, + score = 4, + summary = "норм", + weakSpots = listOf("медленно", "путаю"), + ) +} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/SqliteStoresMigrationTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/SqliteStoresMigrationTest.kt new file mode 100644 index 0000000..9d2455a --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/persistence/SqliteStoresMigrationTest.kt @@ -0,0 +1,43 @@ +package pw.binom.agentik.standalone.persistence + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores + +class SqliteStoresMigrationTest { + + @Test + fun `reflection table is created on fresh inMemory database`() { + val stores = SqliteStores.inMemory() + try { + // Если таблицы нет — insert упадёт. Проверяем insert round-trip. + val r = Reflection( + id = "m1", + conversationId = null, + createdAt = kotlin.time.Instant.parse("2026-09-15T12:00:00Z"), + turnsAnalyzed = 3, + score = 5, + summary = "fresh", + weakSpots = listOf("none"), + ) + kotlinx.coroutines.runBlocking { stores.reflections.insert(r) } + val loaded = kotlinx.coroutines.runBlocking { stores.reflections.get("m1") } + assertNotNull(loaded) + assertEquals("fresh", loaded.summary) + } finally { stores.close() } + } + + @Test + fun `all stores exposed`() { + val stores = SqliteStores.inMemory() + try { + assertNotNull(stores.conversations) + assertNotNull(stores.messages) + assertNotNull(stores.workingMemory) + assertNotNull(stores.reflections) + assertTrue(stores.driver.toString().isNotBlank()) + } finally { stores.close() } + } +}