storage-core: новый KMP-модуль с интерфейсами хранилища

Выносим интерфейсы и data-классы истории диалога (MessageStore / WorkingMemoryStore /
ConversationStore / ReflectionStore + соответствующие sealed-иерархии MessageRecord /
WorkingMemoryEntry / Content / ConversationRecord / Reflection + payload-утилиты) из
:standalone в отдельный KMP-модуль :storage-core (pw.binom.agentik.storage).

Цель — подготовка к Android-портированию и подключению альтернативных реализаций
хранилища без затягивания всей :standalone. Дальше (commit 2/3) — :storage-inmemory
и :storage-sqlite как самостоятельные модули, плюс :storage-android (deferred).

Изменения:
- Новый :storage-core (KMP, commonMain only, jvm + native таргеты) — 12 файлов
- StorageBundle агрегатор (conversationStore + messageStore + workingMemoryStore +
  reflectionStore; SkillStore живёт в :skills и подключается отдельно)
- 11 файлов импортов в :standalone переключены на новый пакет
- SqliteReflectionStore оставлен в :standalone до commit 3 (зависит от
  SQLDelight AgentikDatabase, которую ещё не отвязали от :standalone)
- 4 теста перенесены в :standalone/.../storage/ с обновлённым пакетом
- PayloadTest переехал в :storage-core/commonTest (тестирует чистые типы)

Tests: 264/264 green (179 :standalone + 6 :storage-core + прочие JVM-модули)
This commit is contained in:
2026-09-15 14:39:30 +03:00
parent e192f58cd0
commit 294837daa0
36 changed files with 224 additions and 138 deletions
+4
View File
@@ -44,3 +44,7 @@ include(":memory-md")
// для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART // для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART
// работает через Java 11 base classes (scalar fallback). // работает через Java 11 base classes (scalar fallback).
include(":memory-vector") include(":memory-vector")
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore /
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации —
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred).
include(":storage-core")
+1
View File
@@ -49,6 +49,7 @@ kotlin {
implementation(project(":memory-api")) implementation(project(":memory-api"))
implementation(project(":memory-md")) implementation(project(":memory-md"))
implementation(project(":memory-vector")) implementation(project(":memory-vector"))
implementation(project(":storage-core"))
// litert-openai: JVM-реализация // litert-openai: JVM-реализация
implementation(libs.litert.openai) implementation(libs.litert.openai)
@@ -133,8 +133,8 @@ internal suspend fun recentTurns(stores: SqliteStores, conversationId: String, m
var pendingUser: String? = null var pendingUser: String? = null
for (row in rows) { for (row in rows) {
when (val e = row.entry) { when (val e = row.entry) {
is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.User -> pendingUser = e.content.text() is pw.binom.agentik.storage.WorkingMemoryEntry.User -> pendingUser = e.content.text()
is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Assistant -> { is pw.binom.agentik.storage.WorkingMemoryEntry.Assistant -> {
val user = pendingUser ?: "" val user = pendingUser ?: ""
pendingUser = null pendingUser = null
pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text()) pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text())
@@ -146,5 +146,5 @@ internal suspend fun recentTurns(stores: SqliteStores, conversationId: String, m
} }
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */ /** Текстовое содержимое записей working memory (Text-контент, без картинок). */
internal fun List<pw.binom.agentik.standalone.persistence.Content>.text(): String = internal fun List<pw.binom.agentik.storage.Content>.text(): String =
filterIsInstance<pw.binom.agentik.standalone.persistence.Content.Text>().joinToString("\n") { it.body } filterIsInstance<pw.binom.agentik.storage.Content.Text>().joinToString("\n") { it.body }
@@ -177,7 +177,7 @@ fun main() {
// и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt. // и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt.
val reflector: pw.binom.agentik.standalone.agent.LlmReflector? = val reflector: pw.binom.agentik.standalone.agent.LlmReflector? =
if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null
val recentReflections: List<pw.binom.agentik.standalone.persistence.Reflection> = val recentReflections: List<pw.binom.agentik.storage.Reflection> =
if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking { if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking {
stores.reflections.listRecent(config.reflectionTopK) stores.reflections.listRecent(config.reflectionTopK)
} else emptyList() } else emptyList()
@@ -16,9 +16,9 @@ import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.renderSystemPromptSection 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.storage.ConversationRecord
import pw.binom.agentik.standalone.persistence.Reflection import pw.binom.agentik.storage.Reflection
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry import pw.binom.agentik.storage.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
import kotlin.time.Instant import kotlin.time.Instant
@@ -148,7 +148,7 @@ class ChatAgent(
override fun createConversation(temp: Boolean): ProtoConversation { override fun createConversation(temp: Boolean): ProtoConversation {
val now = now() val now = now()
val id = pw.binom.agentik.standalone.persistence.Ids.new("conv") val id = pw.binom.agentik.storage.Ids.new("conv")
val rec = ConversationRecord( val rec = ConversationRecord(
id = id, id = id,
title = null, title = null,
@@ -27,17 +27,17 @@ import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.proto.Event as ProtoEvent import pw.binom.agentik.proto.Event as ProtoEvent
import pw.binom.agentik.proto.Message as ProtoMessage import pw.binom.agentik.proto.Message as ProtoMessage
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
import pw.binom.agentik.standalone.persistence.Content import pw.binom.agentik.storage.Content
import pw.binom.agentik.standalone.persistence.ConversationRecord import pw.binom.agentik.storage.ConversationRecord
import pw.binom.agentik.standalone.persistence.ConversationStore import pw.binom.agentik.storage.ConversationStore
import pw.binom.agentik.standalone.persistence.MessageContext import pw.binom.agentik.storage.MessageContext
import pw.binom.agentik.standalone.persistence.MessageOrigin import pw.binom.agentik.storage.MessageOrigin
import pw.binom.agentik.standalone.persistence.MessageRecord import pw.binom.agentik.storage.MessageRecord
import pw.binom.agentik.standalone.persistence.TurnTokens import pw.binom.agentik.storage.TurnTokens
import pw.binom.agentik.standalone.persistence.MessageStore import pw.binom.agentik.storage.MessageStore
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.WorkingMemoryRow import pw.binom.agentik.storage.WorkingMemoryRow
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore import pw.binom.agentik.storage.WorkingMemoryStore
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
@@ -113,7 +113,7 @@ class ChatConversation(
/** /**
* Хранилище self-reflection. `null` = reflection отключён. * Хранилище self-reflection. `null` = reflection отключён.
*/ */
private val reflectionStore: pw.binom.agentik.standalone.persistence.ReflectionStore? = null, private val reflectionStore: pw.binom.agentik.storage.ReflectionStore? = null,
/** /**
* Исполнитель рефлексии (one-shot LiteLlm). `null` = reflection отключён. * Исполнитель рефлексии (one-shot LiteLlm). `null` = reflection отключён.
*/ */
@@ -896,7 +896,7 @@ class ChatConversation(
private fun now(): Instant = private fun now(): Instant =
Instant.fromEpochMilliseconds(System.currentTimeMillis()) Instant.fromEpochMilliseconds(System.currentTimeMillis())
private fun newId(prefix: String): String = pw.binom.agentik.standalone.persistence.Ids.new(prefix) private fun newId(prefix: String): String = pw.binom.agentik.storage.Ids.new(prefix)
private fun encodeArgsJson(arguments: Map<String, Any?>): String { private fun encodeArgsJson(arguments: Map<String, Any?>): String {
val el = kotlinx.serialization.json.JsonElement.serializer() val el = kotlinx.serialization.json.JsonElement.serializer()
@@ -5,8 +5,8 @@ import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import pw.binom.agentik.standalone.persistence.Ids import pw.binom.agentik.storage.Ids
import pw.binom.agentik.standalone.persistence.Reflection import pw.binom.agentik.storage.Reflection
import kotlin.time.Clock import kotlin.time.Clock
/** /**
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.NewMemoryNote import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.standalone.persistence.Ids import pw.binom.agentik.storage.Ids
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -6,58 +6,15 @@ 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 mu.KotlinLogging import mu.KotlinLogging
import pw.binom.agentik.storage.Reflection
import pw.binom.agentik.storage.ReflectionEvent
import pw.binom.agentik.storage.ReflectionStore
import pw.binom.agentik.standalone.persistence.sqlite.AgentikDatabase 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.Reflection as DbReflection
import pw.binom.agentik.standalone.persistence.sqlite.ReflectionQueries import pw.binom.agentik.standalone.persistence.sqlite.ReflectionQueries
private val log = KotlinLogging.logger {} 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]. * SQLDelight-реализация [ReflectionStore].
* *
@@ -65,6 +22,11 @@ sealed interface ReflectionEvent {
* парсим минимальным ручным сканером, чтобы не тащить kotlinx-serialization * парсим минимальным ручным сканером, чтобы не тащить kotlinx-serialization
* в этот модуль (он уже есть в commonMain через standalone — но не хочется * в этот модуль (он уже есть в commonMain через standalone — но не хочется
* парсить JSON ради одного массива строк). * парсить JSON ради одного массива строк).
*
* В commit 3 переедет в `:storage-sqlite`. Сейчас остаётся в `:standalone`,
* потому что зависит от SQLDelight'ной `AgentikDatabase`, которую мы ещё
* не отвязали от `:standalone` (`generateMainDatabase` нужно подключить к
* новому модулю).
*/ */
class SqliteReflectionStore( class SqliteReflectionStore(
private val db: AgentikDatabase, private val db: AgentikDatabase,
@@ -1,8 +1,8 @@
package pw.binom.agentik.standalone.persistence.sqlite package pw.binom.agentik.standalone.persistence.sqlite
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.standalone.persistence.ConversationRecord import pw.binom.agentik.storage.ConversationRecord
import pw.binom.agentik.standalone.persistence.ConversationStore import pw.binom.agentik.storage.ConversationStore
/** /**
* SQLite-реализация [ConversationStore]. * SQLite-реализация [ConversationStore].
@@ -1,11 +1,11 @@
package pw.binom.agentik.standalone.persistence.sqlite package pw.binom.agentik.standalone.persistence.sqlite
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.agentik.standalone.persistence.MessageRecord import pw.binom.agentik.storage.MessageRecord
import pw.binom.agentik.standalone.persistence.MessageStore import pw.binom.agentik.storage.MessageStore
import pw.binom.agentik.standalone.persistence.TokenStats import pw.binom.agentik.storage.TokenStats
import pw.binom.agentik.standalone.persistence.decodeBodyPayload import pw.binom.agentik.storage.decodeBodyPayload
import pw.binom.agentik.standalone.persistence.encodeBodyPayload import pw.binom.agentik.storage.encodeBodyPayload
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -3,11 +3,11 @@ package pw.binom.agentik.standalone.persistence.sqlite
import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver 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.storage.ConversationStore
import pw.binom.agentik.standalone.persistence.MessageStore import pw.binom.agentik.storage.MessageStore
import pw.binom.agentik.standalone.persistence.ReflectionStore import pw.binom.agentik.storage.ReflectionStore
import pw.binom.agentik.standalone.persistence.SqliteReflectionStore import pw.binom.agentik.standalone.persistence.SqliteReflectionStore
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore import pw.binom.agentik.storage.WorkingMemoryStore
/** /**
* Корневой объект SQLite-слоя: держит [SqlDriver] и три [WorkingMemoryStore]/[MessageStore]/[ConversationStore]. * Корневой объект SQLite-слоя: держит [SqlDriver] и три [WorkingMemoryStore]/[MessageStore]/[ConversationStore].
@@ -1,9 +1,9 @@
package pw.binom.agentik.standalone.persistence.sqlite package pw.binom.agentik.standalone.persistence.sqlite
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.WorkingMemoryRow import pw.binom.agentik.storage.WorkingMemoryRow
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore import pw.binom.agentik.storage.WorkingMemoryStore
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -90,4 +90,4 @@ private fun Working_memory.toRow(): WorkingMemoryRow {
) )
} }
private fun newId(): String = pw.binom.agentik.standalone.persistence.Ids.new("wm") private fun newId(): String = pw.binom.agentik.storage.Ids.new("wm")
@@ -5,7 +5,7 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.standalone.persistence.Reflection import pw.binom.agentik.storage.Reflection
class ChatAgentReflectionTest { class ChatAgentReflectionTest {
@@ -78,8 +78,8 @@ class ChatAgentTest {
val wm = stores.workingMemory.list(conv.id) val wm = stores.workingMemory.list(conv.id)
assertEquals(1, wm.size) assertEquals(1, wm.size)
val first = wm[0] val first = wm[0]
assertIs<pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.System>(first.entry) val sysEntry = first.entry as pw.binom.agentik.storage.WorkingMemoryEntry.System
assertEquals("be brief", first.entry.text) assertEquals("be brief", sysEntry.text)
} }
@Test @Test
@@ -91,7 +91,7 @@ class ChatAgentTest {
val conv = agent.createConversation(temp = false) as ChatConversation val conv = agent.createConversation(temp = false) as ChatConversation
val system = stores.workingMemory.list(conv.id).first().entry val system = stores.workingMemory.list(conv.id).first().entry
as pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.System as pw.binom.agentik.storage.WorkingMemoryEntry.System
assertTrue("be brief" in system.text) assertTrue("be brief" in system.text)
assertTrue("## Навыки" in system.text) assertTrue("## Навыки" in system.text)
assertTrue("lint" in system.text) assertTrue("lint" in system.text)
@@ -148,10 +148,10 @@ class ChatAgentTest {
// добавим сообщение, чтобы потом убедиться, что каскад сработал // добавим сообщение, чтобы потом убедиться, что каскад сработал
stores.messages.append( stores.messages.append(
pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage( pw.binom.agentik.storage.MessageRecord.UserMessage(
id = "m1", id = "m1",
conversationId = id, conversationId = id,
content = listOf(pw.binom.agentik.standalone.persistence.Content.Text("hi")), content = listOf(pw.binom.agentik.storage.Content.Text("hi")),
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000), createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
), ),
) )
@@ -178,11 +178,11 @@ class ChatAgentTest {
// user message записан в audit + working memory // user message записан в audit + working memory
val msgs = stores.messages.listAll(conv.id) val msgs = stores.messages.listAll(conv.id)
assertEquals(2, msgs.size) assertEquals(2, msgs.size)
assertEquals("hi", (msgs[0] as pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage).content.let { assertEquals("hi", (msgs[0] as pw.binom.agentik.storage.MessageRecord.UserMessage).content.let {
(it[0] as pw.binom.agentik.standalone.persistence.Content.Text).body (it[0] as pw.binom.agentik.storage.Content.Text).body
}) })
assertEquals("hello back", (msgs[1] as pw.binom.agentik.standalone.persistence.MessageRecord.AssistantMessage).content.let { assertEquals("hello back", (msgs[1] as pw.binom.agentik.storage.MessageRecord.AssistantMessage).content.let {
(it[0] as pw.binom.agentik.standalone.persistence.Content.Text).body (it[0] as pw.binom.agentik.storage.Content.Text).body
}) })
} }
@@ -305,8 +305,8 @@ class ChatAgentTest {
val msgs = stores.messages.listAll(conv.id) val msgs = stores.messages.listAll(conv.id)
assertEquals(2, msgs.size) assertEquals(2, msgs.size)
assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage>(msgs[0]) assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
val err = assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.Error>(msgs[1]) val err = assertIs<pw.binom.agentik.storage.MessageRecord.Error>(msgs[1])
assertEquals("boom from llm", err.message) assertEquals("boom from llm", err.message)
// backfill через getMessages (polling/reconnect) тоже видит ошибку // backfill через getMessages (polling/reconnect) тоже видит ошибку
@@ -338,7 +338,7 @@ class ChatAgentTest {
// user сообщение в audit должно быть, assistant — нет (был отменён) // user сообщение в audit должно быть, assistant — нет (был отменён)
val msgs = stores.messages.listAll(conv.id) val msgs = stores.messages.listAll(conv.id)
assertEquals(1, msgs.size) assertEquals(1, msgs.size)
assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage>(msgs[0]) assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
} }
@Test @Test
@@ -88,7 +88,7 @@ class CompactionTest {
} }
val wm = stores.workingMemory.list(conv.id) val wm = stores.workingMemory.list(conv.id)
// Без compaction все ходы остаются в памяти (System + 10 user/assistant = 21 строк). // Без compaction все ходы остаются в памяти (System + 10 user/assistant = 21 строк).
val summaries = wm.filter { it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary } val summaries = wm.filter { it.entry is pw.binom.agentik.storage.WorkingMemoryEntry.Summary }
assertEquals(0, summaries.size, "compaction must not run without contextWindow") assertEquals(0, summaries.size, "compaction must not run without contextWindow")
} }
@@ -100,7 +100,7 @@ class CompactionTest {
conv.send(listOf(ProtoContent.Text("first"))) conv.send(listOf(ProtoContent.Text("first")))
val wm = stores.workingMemory.list(conv.id) val wm = stores.workingMemory.list(conv.id)
// System + User + Assistant = 3. Без compactor — никаких Summary. // System + User + Assistant = 3. Без compactor — никаких Summary.
val summaries = wm.filter { it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary } val summaries = wm.filter { it.entry is pw.binom.agentik.storage.WorkingMemoryEntry.Summary }
assertEquals(0, summaries.size, "no compaction runs without compactor") assertEquals(0, summaries.size, "no compaction runs without compactor")
} }
@@ -118,10 +118,10 @@ class CompactionTest {
assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold") assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold")
// В working memory должна появиться Summary. // В working memory должна появиться Summary.
val wm = stores.workingMemory.list(conv.id) val wm = stores.workingMemory.list(conv.id)
val summaries = wm.filter { it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary } val summaries = wm.filter { it.entry is pw.binom.agentik.storage.WorkingMemoryEntry.Summary }
assertTrue(summaries.isNotEmpty(), "at least one Summary entry should be present after compaction") assertTrue(summaries.isNotEmpty(), "at least one Summary entry should be present after compaction")
// Summary-текст — то, что вернул наш compactor. // Summary-текст — то, что вернул наш compactor.
val summaryText = (summaries.first().entry as pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary).text val summaryText = (summaries.first().entry as pw.binom.agentik.storage.WorkingMemoryEntry.Summary).text
assertTrue(summaryText.startsWith("**Goal**"), "summary text should come from compactor: $summaryText") assertTrue(summaryText.startsWith("**Goal**"), "summary text should come from compactor: $summaryText")
} }
@@ -161,8 +161,8 @@ class CompactionTest {
// Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов. // Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов.
// KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты. // KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты.
val userAssistantCount = wm.count { val userAssistantCount = wm.count {
it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.User || it.entry is pw.binom.agentik.storage.WorkingMemoryEntry.User ||
it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Assistant it.entry is pw.binom.agentik.storage.WorkingMemoryEntry.Assistant
} }
// Минимум 1 ход остаётся (KEEP_RECENT_TURNS). // Минимум 1 ход остаётся (KEEP_RECENT_TURNS).
assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved") assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved")
@@ -1,9 +1,9 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.standalone.agent
import pw.binom.agentik.standalone.persistence.MessageContext import pw.binom.agentik.storage.MessageContext
import pw.binom.agentik.standalone.persistence.MessageOrigin.EVENT import pw.binom.agentik.storage.MessageOrigin.EVENT
import pw.binom.agentik.standalone.persistence.MessageOrigin.SYSTEM import pw.binom.agentik.storage.MessageOrigin.SYSTEM
import pw.binom.agentik.standalone.persistence.MessageOrigin.USER import pw.binom.agentik.storage.MessageOrigin.USER
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -29,7 +29,7 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
@@ -1,10 +1,12 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.standalone.persistence.decodeStringArray
import pw.binom.agentik.standalone.persistence.encodeStringArray
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
class ReflectionStoreTest { class ReflectionStoreTest {
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
+32
View File
@@ -0,0 +1,32 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
// Зеркалит набор :proto / :server / :memory-api — KMP-модуль с интерфейсами
// хранилища и разговорной истории, без платформенного IO. Конкретные
// реализации (sqlite, in-memory, android) живут в отдельных модулях.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlin.time.Instant import kotlin.time.Instant
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlin.time.Instant import kotlin.time.Instant
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -8,9 +8,8 @@ import kotlinx.serialization.json.JsonElement
* Контекст инициации хода (кто/что и почему). * Контекст инициации хода (кто/что и почему).
* *
* Дубликат типа из `:proto` (`pw.binom.agentik.proto.MessageContext`): * Дубликат типа из `:proto` (`pw.binom.agentik.proto.MessageContext`):
* живёт в `:standalone/persistence` чтобы не тащить `:proto` в слой * живёт в `:storage-core` чтобы не тащить `:proto` в слой хранения данных.
* хранения данных. Маппинг между ними — в `MessageRecord.toProto()` / * Маппинг между ними — в `MessageRecord.toProto()` / `ProtoMessage.toStorage()`.
* `ProtoMessage.toStorage()`.
* *
* Используется: * Используется:
* - в `MessageRecord.UserMessage.context` — фиксируется в audit log; * - в `MessageRecord.UserMessage.context` — фиксируется в audit log;
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -33,7 +33,7 @@ data class TurnTokens(
/** /**
* Запись в таблице `message` (append-only audit) и `working_memory` (mutable view). * Запись в таблице `message` (append-only audit) и `working_memory` (mutable view).
* *
* Используется sealed-иерархию: подтипы `User`/`Assistant`/`ToolCall`/`ToolResult` * Использует sealed-иерархию: подтипы `User`/`Assistant`/`ToolCall`/`ToolResult`
* живут и там, и там. `Summary`/`System` — только в `working_memory` * живут и там, и там. `Summary`/`System` — только в `working_memory`
* (синтетические строки, созданные при суммаризации или как system-prompt). * (синтетические строки, созданные при суммаризации или как system-prompt).
* *
@@ -1,5 +1,6 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -39,9 +40,17 @@ interface MessageStore : AutoCloseable {
/** Все сообщения диалога, отсортированные по `createdAt ASC` (для rebuild working memory). */ /** Все сообщения диалога, отсортированные по `createdAt ASC` (для rebuild working memory). */
suspend fun listAll(conversationId: String): List<MessageRecord> suspend fun listAll(conversationId: String): List<MessageRecord>
/** Лайв-стрим новых сообщений (для SSE-подписчиков). По умолчанию — пустой. */
fun events(): Flow<MessageEvent> = kotlinx.coroutines.flow.emptyFlow()
/** /**
* Суммарная token-статистика по диалогу: input/output/turns. Один проход * Суммарная token-статистика по диалогу: input/output/turns. Один проход
* по всем assistant-сообщениям. Дёшево (на практике < 1мс на SQLite). * по всем assistant-сообщениям. Дёшево (на практике < 1мс на SQLite).
*/ */
suspend fun tokenStats(conversationId: String): TokenStats suspend fun tokenStats(conversationId: String): TokenStats
} }
sealed interface MessageEvent {
val conversationId: String
data class Appended(override val conversationId: String, val record: MessageRecord) : MessageEvent
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -27,7 +27,7 @@ private val bodyJson = Json {
} }
@Serializable @Serializable
internal data class MessageBodyPayload( data class MessageBodyPayload(
val content: List<Content>, val content: List<Content>,
@SerialName("context") @SerialName("context")
val context: MessageContext? = null, val context: MessageContext? = null,
@@ -0,0 +1,51 @@
package pw.binom.agentik.storage
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/**
* 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` таблицу (impl в
* `:storage-sqlite`) или in-memory (impl в `:storage-inmemory`).
*
* Рефлексии — 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
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.storage
/**
* Агрегатор всех storage-интерфейсов, нужных агенту для работы с историей диалога.
*
* Реализации:
* - [SqliteStorageBundle] — JVM-only, основная. SQLDelight + SQLite.
* - [InMemoryStorageBundle] — Map-based, для тестов.
* - `:storage-android` — Android-специфичная (deferred, Android-сборка в планах).
*
* `SkillStore` НЕ входит сюда — он живёт в модуле `:skills` (другая ответственность:
* не сообщения/рефлексии, а контент-файлы навыков) и принимается отдельно в `ChatAgent`.
*
* AutoCloseable: один `close()` закрывает все четыре store'а. В реализациях,
* которые не владеют ресурсами (in-memory), close — no-op.
*/
data class StorageBundle(
val conversationStore: ConversationStore,
val messageStore: MessageStore,
val workingMemoryStore: WorkingMemoryStore,
val reflectionStore: ReflectionStore,
) : AutoCloseable {
override fun close() {
conversationStore.close()
messageStore.close()
workingMemoryStore.close()
reflectionStore.close()
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlin.time.Instant import kotlin.time.Instant
@@ -1,10 +1,7 @@
package pw.binom.agentik.standalone.persistence package pw.binom.agentik.storage
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import pw.binom.agentik.standalone.persistence.MessageOrigin.EVENT
import pw.binom.agentik.standalone.persistence.MessageOrigin.SYSTEM
import pw.binom.agentik.standalone.persistence.MessageOrigin.USER
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
@@ -19,7 +16,7 @@ class PayloadTest {
@Test @Test
fun `user context roundtrips through MessageBodyPayload`() { fun `user context roundtrips through MessageBodyPayload`() {
val ctx = MessageContext( val ctx = MessageContext(
origin = USER, origin = MessageOrigin.USER,
description = "irc PRIVMSG", description = "irc PRIVMSG",
sourceId = "irc:agentik", sourceId = "irc:agentik",
) )
@@ -36,7 +33,7 @@ class PayloadTest {
@Test @Test
fun `event context roundtrips with metadata`() { fun `event context roundtrips with metadata`() {
val ctx = MessageContext( val ctx = MessageContext(
origin = EVENT, origin = MessageOrigin.EVENT,
description = "scheduled cron morning-briefing", description = "scheduled cron morning-briefing",
sourceId = "cron-42", sourceId = "cron-42",
metadata = buildJsonObject { metadata = buildJsonObject {
@@ -46,7 +43,7 @@ class PayloadTest {
) )
val encoded = encodeBodyPayload(listOf(Content.Text("wake up")), ctx) val encoded = encodeBodyPayload(listOf(Content.Text("wake up")), ctx)
val decoded = decodeBodyPayload(encoded) val decoded = decodeBodyPayload(encoded)
assertEquals(EVENT, decoded.context?.origin) assertEquals(MessageOrigin.EVENT, decoded.context?.origin)
assertEquals("scheduled cron morning-briefing", decoded.context?.description) assertEquals("scheduled cron morning-briefing", decoded.context?.description)
assertEquals("cron-42", decoded.context?.sourceId) assertEquals("cron-42", decoded.context?.sourceId)
assertEquals("0 8 * * *", (decoded.context?.metadata?.let { (it as kotlinx.serialization.json.JsonObject)["rule"] } as? JsonPrimitive)?.content) assertEquals("0 8 * * *", (decoded.context?.metadata?.let { (it as kotlinx.serialization.json.JsonObject)["rule"] } as? JsonPrimitive)?.content)
@@ -73,10 +70,10 @@ class PayloadTest {
@Test @Test
fun `system context with description only roundtrips`() { fun `system context with description only roundtrips`() {
val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting") val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "agent startup greeting")
val encoded = encodeBodyPayload(listOf(Content.Text("boot")), ctx) val encoded = encodeBodyPayload(listOf(Content.Text("boot")), ctx)
val decoded = decodeBodyPayload(encoded) val decoded = decodeBodyPayload(encoded)
assertEquals(SYSTEM, decoded.context?.origin) assertEquals(MessageOrigin.SYSTEM, decoded.context?.origin)
assertEquals("agent startup greeting", decoded.context?.description) assertEquals("agent startup greeting", decoded.context?.description)
assertNull(decoded.context?.sourceId) assertNull(decoded.context?.sourceId)
assertNull(decoded.context?.metadata) assertNull(decoded.context?.metadata)