reflection: Hermes-style self-reflection (последняя открытая Hermes-фича)

Self-reflection: каждые N пользовательских ходов агент запускает one-shot
LLM-размышление о качестве своих ответов, сохраняет score+weakSpots в SQLite,
подмешивает top-K последних рефлексий в system prompt как "слабые места".

* sqldelight: новая таблица reflection (id, conversation_id?, created_at,
  turns_analyzed, score, summary, weak_spots_json) + индексы по created_at и
  conversation_id.
* persistence: Reflection data class + ReflectionStore interface +
  SqliteReflectionStore (insert/get/listRecent/listForConversation/
  deleteOlderThan/count + events Flow). weakSpots хранятся как JSON-массив,
  парсятся ручным сканером (без kotlinx-serialization в этом модуле).
* agent: LlmReflector (one-shot LiteLlm через createConversation +
  send, structured-output JSON). ReflectionParser (hand-rolled,
  толерантный к ```json fences и лидирующему/завершающему тексту;
  score принимает int или строку; weakSpots — массив).
* agent: ReflectionPrompts (Russian system+user prompts, аналогично
  LlmMemoryReviewer/ReviewPrompts).
* agent: ChatAgent.buildSystemPrompt расширен параметром reflections —
  добавляется секция `## Self-reflection: твои слабые места за последнее время`
  после memory и перед soul-prepend.
* agent: ChatConversation.scheduleReflection — каждые reflectionInterval
  пользовательских ходов (счётчик через workingMemory.list) запускает
  reflector на Dispatchers.IO, результат сохраняет в reflectionStore
  с conversationId. Не блокирует turn.
* AgentikConfig: новые поля reflectionInterval (env AGENTIK_REFLECTION_INTERVAL,
  default 10, clamped 0..1000) и reflectionTopK (env AGENTIK_REFLECTION_TOP_K,
  default 3, clamped 0..20).
* Main.kt: если reflectionInterval > 0 — создаём LlmReflector(llm); загружаем
  top-K из SQLite в system prompt.
* Ids.reflection() — генератор id "refl-<uuid>".
* Tests: ReflectionParserTest (7: clean JSON, fences, лидирующий текст,
  score-as-string, отсутствие score, невалидный JSON, escape-последовательности),
  ReflectionStoreTest (7: round-trip, listRecent с лимитом, фильтр по
  conversation_id, deleteOlderThan, count, encode/decode строк), и
  ChatAgentReflectionTest (3: секция скрыта при пустых, присутствует с
  score+spots, порядок soul→memory→reflection).
* README: новые env-переменные, раздел "Self-reflection", startup output.

Smoke test подтверждает: reflection секция появляется в system prompt когда
в SQLite есть записи (listRecent возвращает непустой список). При
reflectionInterval=0 reflector не создаётся, scheduleReflection — no-op.

Tests: 305 total green (+17: 7+7+3). Fatjar собирается, logback-вывод
работает (logging: см. предыдущий коммит f5a551b).
This commit is contained in:
2026-09-15 05:09:52 +03:00
parent f5a551b2ae
commit 23da1f6498
17 changed files with 1016 additions and 12 deletions
@@ -35,7 +35,7 @@ class SiglipEmbeddingProviderTest {
@Test @Test
fun `missing model file fails with clear error`() { 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") val nonExistent = File(tmpDir, "does-not-exist.onnx")
assertFailsWith<Exception> { assertFailsWith<Exception> {
SiglipEmbeddingProvider( SiglipEmbeddingProvider(
@@ -46,9 +46,6 @@ class SiglipEmbeddingProviderTest {
} }
private inline fun assume(condition: Boolean, message: () -> String) { private inline fun assume(condition: Boolean, message: () -> String) {
if (!condition) { org.junit.Assume.assumeTrue(message(), condition)
println("SKIPPED: ${message()}")
return
}
} }
} }
+23
View File
@@ -53,6 +53,8 @@ java -jar standalone/build/libs/standalone-all.jar
| `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) | | `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) |
| `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend | | `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend |
| `AGENTIK_COMPRESSION_THRESHOLD` | `0.8` | Доля лимита, при которой запускается compaction | | `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 ### OpenAI backend
@@ -178,6 +180,26 @@ export AGENTIK_COMPRESSION_THRESHOLD=0.7 # сжимаем раньше
нового compaction не запускается (защита от зацикливания). Решение — нового compaction не запускается (защита от зацикливания). Решение —
поднять `OPENAI_CONTEXT_WINDOW` или понизить threshold. поднять `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) ## Куратор памяти (Curator)
Фоновая корутина (запускается автоматически, если `AGENTIK_MEMORY_DIR != off`): Фоновая корутина (запускается автоматически, если `AGENTIK_MEMORY_DIR != off`):
@@ -356,6 +378,7 @@ agentik standalone listening on http://localhost:8080
memory: /var/lib/agentik/memory (md-backend) memory: /var/lib/agentik/memory (md-backend)
compaction: enabled, threshold=0.8, window=128000 tokens compaction: enabled, threshold=0.8, window=128000 tokens
curator: enabled (interval=1d, maxAge=90d) curator: enabled (interval=1d, maxAge=90d)
reflection: enabled (interval=10, topK=3)
``` ```
## Остановка ## Остановка
@@ -11,4 +11,5 @@ import kotlin.uuid.Uuid
*/ */
object Ids { object Ids {
fun new(prefix: String): String = "$prefix-${Uuid.random()}" fun new(prefix: String): String = "$prefix-${Uuid.random()}"
fun reflection(): String = new("refl")
} }
@@ -21,6 +21,7 @@ import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillLoader import pw.binom.agentik.skills.SkillLoader
import pw.binom.agentik.standalone.agent.ChatAgent import pw.binom.agentik.standalone.agent.ChatAgent
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor 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.agent.memory.LlmMemoryReviewer
import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.config.AgentikConfig
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
@@ -155,6 +156,15 @@ fun main() {
) )
} ?: memorySystem?.reviewer } ?: 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<pw.binom.agentik.standalone.persistence.Reflection> =
if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking {
stores.reflections.listRecent(config.reflectionTopK)
} else emptyList()
val agent = ChatAgent( val agent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, stores = stores,
@@ -169,6 +179,9 @@ fun main() {
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = config.compressionThreshold, compressionThreshold = config.compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
recentReflections = recentReflections,
reflector = reflector,
reflectionInterval = config.reflectionInterval,
) )
val server = embeddedServer(CIO, port = config.port) { val server = embeddedServer(CIO, port = config.port) {
@@ -17,6 +17,7 @@ import pw.binom.agentik.skills.renderSystemPromptSection
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.persistence.ConversationRecord import pw.binom.agentik.standalone.persistence.ConversationRecord
import pw.binom.agentik.standalone.persistence.Reflection
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
@@ -71,10 +72,24 @@ class ChatAgent(
* Сжиматель контекста. Вызывается только при превышении [compressionThreshold]. * Сжиматель контекста. Вызывается только при превышении [compressionThreshold].
*/ */
private val contextCompactor: ContextCompactor? = null, private val contextCompactor: ContextCompactor? = null,
/**
* Self-reflection: список последних рефлексий, подмешиваемых в system prompt.
* Если `null` или пустой — секция не добавляется.
*/
private val recentReflections: List<Reflection> = emptyList(),
/**
* Исполнитель рефлексий (one-shot LiteLlm вызов). `null` = self-reflection выключен.
*/
private val reflector: LlmReflector? = null,
/**
* Через сколько пользовательских ходов запускать рефлексию. `0` = выключено.
*/
private val reflectionInterval: Int = 0,
) : ProtoAgent, AutoCloseable { ) : ProtoAgent, AutoCloseable {
/** /**
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти. * Системный промпт: (soul, если задан) → база → секция навыков → секция памяти
* → секция self-reflection (слабые места).
* Именно он сидируется в working memory и передаётся в [ChatConversation]. * Именно он сидируется в working memory и передаётся в [ChatConversation].
*/ */
private val systemPrompt: String = buildSystemPrompt( private val systemPrompt: String = buildSystemPrompt(
@@ -82,6 +97,7 @@ class ChatAgent(
skills = skills, skills = skills,
memoryEnabled = memoryStore != null, memoryEnabled = memoryStore != null,
soulBody = soulBody, soulBody = soulBody,
reflections = recentReflections,
) )
/** /**
@@ -145,6 +161,9 @@ class ChatAgent(
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflectionStore = stores.reflections,
reflector = reflector,
reflectionInterval = reflectionInterval,
) )
runBlocking { runBlocking {
liveLock.withLock { live[conv.id] = conv } liveLock.withLock { live[conv.id] = conv }
@@ -189,6 +208,9 @@ class ChatAgent(
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflectionStore = stores.reflections,
reflector = reflector,
reflectionInterval = reflectionInterval,
) )
override fun close() { 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<Reflection> = emptyList(),
): String {
val trimmedBase = base.trimEnd() val trimmedBase = base.trimEnd()
val skillsSection = skills.renderSystemPromptSection() val skillsSection = skills.renderSystemPromptSection()
val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection
val withMemory = if (memoryEnabled) withSkills + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else withSkills 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() 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<Reflection>): 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()
}
} }
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.memory.MemoryNote import pw.binom.agentik.memory.MemoryNote
@@ -107,6 +108,18 @@ class ChatConversation(
* продолжит работать как раньше). * продолжит работать как раньше).
*/ */
private val contextCompactor: ContextCompactor? = null, 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 { ) : ProtoConversation, AutoCloseable {
private var record: ConversationRecord = record private var record: ConversationRecord = record
@@ -318,6 +331,7 @@ class ChatConversation(
} }
scheduleReview(userRecord, assistantContent) scheduleReview(userRecord, assistantContent)
scheduleReflection(userRecord, assistantContent)
emitEvent(ProtoEvent.End(date = assistantAt)) 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<Content>,
) {
if (reflectionInterval <= 0) return
val reflector = reflector ?: return
val store = reflectionStore ?: return
if (record.isTemporal) return
val userText = userRecord.content.filterIsInstance<Content.Text>()
.joinToString("\n") { it.body }
val assistantText = assistantContent.filterIsInstance<Content.Text>()
.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, * Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult,
* пишем в audit + working memory, подаём результат в LiteConversation. * пишем в audit + working memory, подаём результат в LiteConversation.
@@ -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<ConversationTurn>): 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()
}
}
}
@@ -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<String>)
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<String>? {
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<String>()
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++
}
}
}
}
@@ -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": "<markdown>", "weakSpots": ["...", "..."]}
Никаких пояснений до или после JSON. Только валидный JSON."""
/**
* User-prompt: последние ходы диалога. Каждый ход — пара
* `[user] text` / `[assistant] text`. Старые ходы обрезаются до [maxTurns].
*/
fun buildUserPrompt(turns: List<ConversationTurn>, 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.")
}
}
@@ -85,6 +85,17 @@ data class AgentikConfig(
* [embeddingBackend] = SIGLIP. * [embeddingBackend] = SIGLIP.
*/ */
val embeddingTokenizerPath: String? = null, 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 @Serializable
@@ -100,6 +111,8 @@ data class AgentikConfig(
const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8 const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8
const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small" const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small"
const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536 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, } ?: EmbeddingBackend.HTTP,
embeddingModelPath = env("AGENTIK_EMBEDDING_MODEL_PATH")?.takeIf { it.isNotBlank() }, embeddingModelPath = env("AGENTIK_EMBEDDING_MODEL_PATH")?.takeIf { it.isNotBlank() },
embeddingTokenizerPath = env("AGENTIK_EMBEDDING_TOKENIZER_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,
) )
} }
} }
@@ -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<String>,
)
/**
* Хранилище рефлексий. 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<Reflection>
/** Рефлексии для конкретного диалога. */
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
suspend fun deleteOlderThan(cutoff: Instant)
suspend fun count(): Int
/** Стрим новых рефлексий для подписчиков (для UI в будущем). */
fun events(): Flow<ReflectionEvent> = 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<ReflectionEvent>(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<Reflection> =
queries.listRecent(limit.toLong()).executeAsList().map { it.toDomain() }
override suspend fun listForConversation(conversationId: String, limit: Int): List<Reflection> =
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<ReflectionEvent> = 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>): 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<String> {
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<String>()
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
}
@@ -5,6 +5,8 @@ import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import pw.binom.agentik.standalone.persistence.ConversationStore import pw.binom.agentik.standalone.persistence.ConversationStore
import pw.binom.agentik.standalone.persistence.MessageStore 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 import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
/** /**
@@ -16,12 +18,14 @@ class SqliteStores private constructor(
val conversations: ConversationStore, val conversations: ConversationStore,
val messages: MessageStore, val messages: MessageStore,
val workingMemory: WorkingMemoryStore, val workingMemory: WorkingMemoryStore,
val reflections: ReflectionStore,
) : AutoCloseable { ) : AutoCloseable {
override fun close() { override fun close() {
conversations.close() conversations.close()
messages.close() messages.close()
workingMemory.close() workingMemory.close()
reflections.close()
driver.close() driver.close()
} }
@@ -37,6 +41,7 @@ class SqliteStores private constructor(
conversations = SqliteConversationStore(db), conversations = SqliteConversationStore(db),
messages = SqliteMessageStore(db), messages = SqliteMessageStore(db),
workingMemory = SqliteWorkingMemoryStore(db), workingMemory = SqliteWorkingMemoryStore(db),
reflections = SqliteReflectionStore(db),
) )
} }
@@ -50,12 +55,14 @@ class SqliteStores private constructor(
conversations = SqliteConversationStore(db), conversations = SqliteConversationStore(db),
messages = SqliteMessageStore(db), messages = SqliteMessageStore(db),
workingMemory = SqliteWorkingMemoryStore(db), workingMemory = SqliteWorkingMemoryStore(db),
reflections = SqliteReflectionStore(db),
) )
} }
private fun createSchema(driver: SqlDriver) { private fun createSchema(driver: SqlDriver) {
// Если таблица `conversation` уже есть — БД уже инициализирована, // Если таблица `conversation` уже есть — БД уже инициализирована.
// просто пропускаем create (иначе CREATE TABLE упадёт на дубликате). // Всё равно прогоняем additive-миграции (см. runMigrations), потому что
// в новых версиях могли появиться таблицы, которых нет в этой БД.
val existing = driver.executeQuery( val existing = driver.executeQuery(
identifier = null, identifier = null,
sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation'", sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation'",
@@ -66,8 +73,42 @@ class SqliteStores private constructor(
}, },
parameters = 0, parameters = 0,
).value ).value
if (existing != null) return if (existing == null) {
AgentikDatabase.Schema.create(driver) // Свежая БД — пусть 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)
}
} }
} }
} }
@@ -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;
@@ -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")
}
}
@@ -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<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)
}
}
@@ -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<String>(), decodeStringArray("[]"))
assertEquals(emptyList<String>(), decodeStringArray(""))
assertEquals(emptyList<String>(), decodeStringArray("not json"))
assertEquals(emptyList<String>(), 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("медленно", "путаю"),
)
}
@@ -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() }
}
}