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:
@@ -44,3 +44,7 @@ include(":memory-md")
|
||||
// для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART
|
||||
// работает через Java 11 base classes (scalar fallback).
|
||||
include(":memory-vector")
|
||||
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore /
|
||||
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации —
|
||||
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred).
|
||||
include(":storage-core")
|
||||
|
||||
@@ -49,6 +49,7 @@ kotlin {
|
||||
implementation(project(":memory-api"))
|
||||
implementation(project(":memory-md"))
|
||||
implementation(project(":memory-vector"))
|
||||
implementation(project(":storage-core"))
|
||||
|
||||
// litert-openai: JVM-реализация
|
||||
implementation(libs.litert.openai)
|
||||
|
||||
@@ -133,8 +133,8 @@ internal suspend fun recentTurns(stores: SqliteStores, conversationId: String, m
|
||||
var pendingUser: String? = null
|
||||
for (row in rows) {
|
||||
when (val e = row.entry) {
|
||||
is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.User -> pendingUser = e.content.text()
|
||||
is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Assistant -> {
|
||||
is pw.binom.agentik.storage.WorkingMemoryEntry.User -> pendingUser = e.content.text()
|
||||
is pw.binom.agentik.storage.WorkingMemoryEntry.Assistant -> {
|
||||
val user = pendingUser ?: ""
|
||||
pendingUser = null
|
||||
pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text())
|
||||
@@ -146,5 +146,5 @@ internal suspend fun recentTurns(stores: SqliteStores, conversationId: String, m
|
||||
}
|
||||
|
||||
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
|
||||
internal fun List<pw.binom.agentik.standalone.persistence.Content>.text(): String =
|
||||
filterIsInstance<pw.binom.agentik.standalone.persistence.Content.Text>().joinToString("\n") { it.body }
|
||||
internal fun List<pw.binom.agentik.storage.Content>.text(): String =
|
||||
filterIsInstance<pw.binom.agentik.storage.Content.Text>().joinToString("\n") { it.body }
|
||||
|
||||
@@ -177,7 +177,7 @@ fun main() {
|
||||
// и 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> =
|
||||
val recentReflections: List<pw.binom.agentik.storage.Reflection> =
|
||||
if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking {
|
||||
stores.reflections.listRecent(config.reflectionTopK)
|
||||
} else emptyList()
|
||||
|
||||
@@ -16,9 +16,9 @@ import pw.binom.agentik.skills.SkillCatalog
|
||||
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.storage.ConversationRecord
|
||||
import pw.binom.agentik.storage.Reflection
|
||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
import pw.binom.litert.LiteLlm
|
||||
import kotlin.time.Instant
|
||||
@@ -148,7 +148,7 @@ class ChatAgent(
|
||||
|
||||
override fun createConversation(temp: Boolean): ProtoConversation {
|
||||
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(
|
||||
id = id,
|
||||
title = null,
|
||||
|
||||
+13
-13
@@ -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.Message as ProtoMessage
|
||||
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
||||
import pw.binom.agentik.standalone.persistence.Content
|
||||
import pw.binom.agentik.standalone.persistence.ConversationRecord
|
||||
import pw.binom.agentik.standalone.persistence.ConversationStore
|
||||
import pw.binom.agentik.standalone.persistence.MessageContext
|
||||
import pw.binom.agentik.standalone.persistence.MessageOrigin
|
||||
import pw.binom.agentik.standalone.persistence.MessageRecord
|
||||
import pw.binom.agentik.standalone.persistence.TurnTokens
|
||||
import pw.binom.agentik.standalone.persistence.MessageStore
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryRow
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
|
||||
import pw.binom.agentik.storage.Content
|
||||
import pw.binom.agentik.storage.ConversationRecord
|
||||
import pw.binom.agentik.storage.ConversationStore
|
||||
import pw.binom.agentik.storage.MessageContext
|
||||
import pw.binom.agentik.storage.MessageOrigin
|
||||
import pw.binom.agentik.storage.MessageRecord
|
||||
import pw.binom.agentik.storage.TurnTokens
|
||||
import pw.binom.agentik.storage.MessageStore
|
||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
||||
import pw.binom.agentik.storage.WorkingMemoryRow
|
||||
import pw.binom.agentik.storage.WorkingMemoryStore
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.litert.LiteConversation
|
||||
@@ -113,7 +113,7 @@ class ChatConversation(
|
||||
/**
|
||||
* Хранилище 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 отключён.
|
||||
*/
|
||||
@@ -896,7 +896,7 @@ class ChatConversation(
|
||||
private fun now(): Instant =
|
||||
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 {
|
||||
val el = kotlinx.serialization.json.JsonElement.serializer()
|
||||
|
||||
@@ -5,8 +5,8 @@ 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 pw.binom.agentik.storage.Ids
|
||||
import pw.binom.agentik.storage.Reflection
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||
import pw.binom.agentik.memory.NewMemoryNote
|
||||
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 kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
+8
-46
@@ -6,58 +6,15 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
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.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].
|
||||
*
|
||||
@@ -65,6 +22,11 @@ sealed interface ReflectionEvent {
|
||||
* парсим минимальным ручным сканером, чтобы не тащить kotlinx-serialization
|
||||
* в этот модуль (он уже есть в commonMain через standalone — но не хочется
|
||||
* парсить JSON ради одного массива строк).
|
||||
*
|
||||
* В commit 3 переедет в `:storage-sqlite`. Сейчас остаётся в `:standalone`,
|
||||
* потому что зависит от SQLDelight'ной `AgentikDatabase`, которую мы ещё
|
||||
* не отвязали от `:standalone` (`generateMainDatabase` нужно подключить к
|
||||
* новому модулю).
|
||||
*/
|
||||
class SqliteReflectionStore(
|
||||
private val db: AgentikDatabase,
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
package pw.binom.agentik.standalone.persistence.sqlite
|
||||
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.standalone.persistence.ConversationRecord
|
||||
import pw.binom.agentik.standalone.persistence.ConversationStore
|
||||
import pw.binom.agentik.storage.ConversationRecord
|
||||
import pw.binom.agentik.storage.ConversationStore
|
||||
|
||||
/**
|
||||
* SQLite-реализация [ConversationStore].
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
package pw.binom.agentik.standalone.persistence.sqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.standalone.persistence.MessageRecord
|
||||
import pw.binom.agentik.standalone.persistence.MessageStore
|
||||
import pw.binom.agentik.standalone.persistence.TokenStats
|
||||
import pw.binom.agentik.standalone.persistence.decodeBodyPayload
|
||||
import pw.binom.agentik.standalone.persistence.encodeBodyPayload
|
||||
import pw.binom.agentik.storage.MessageRecord
|
||||
import pw.binom.agentik.storage.MessageStore
|
||||
import pw.binom.agentik.storage.TokenStats
|
||||
import pw.binom.agentik.storage.decodeBodyPayload
|
||||
import pw.binom.agentik.storage.encodeBodyPayload
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
|
||||
+4
-4
@@ -3,11 +3,11 @@ package pw.binom.agentik.standalone.persistence.sqlite
|
||||
import app.cash.sqldelight.db.QueryResult
|
||||
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.storage.ConversationStore
|
||||
import pw.binom.agentik.storage.MessageStore
|
||||
import pw.binom.agentik.storage.ReflectionStore
|
||||
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].
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package pw.binom.agentik.standalone.persistence.sqlite
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryRow
|
||||
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
|
||||
import pw.binom.agentik.storage.WorkingMemoryEntry
|
||||
import pw.binom.agentik.storage.WorkingMemoryRow
|
||||
import pw.binom.agentik.storage.WorkingMemoryStore
|
||||
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")
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ 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
|
||||
import pw.binom.agentik.storage.Reflection
|
||||
|
||||
class ChatAgentReflectionTest {
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ class ChatAgentTest {
|
||||
val wm = stores.workingMemory.list(conv.id)
|
||||
assertEquals(1, wm.size)
|
||||
val first = wm[0]
|
||||
assertIs<pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.System>(first.entry)
|
||||
assertEquals("be brief", first.entry.text)
|
||||
val sysEntry = first.entry as pw.binom.agentik.storage.WorkingMemoryEntry.System
|
||||
assertEquals("be brief", sysEntry.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -91,7 +91,7 @@ class ChatAgentTest {
|
||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||
|
||||
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("## Навыки" in system.text)
|
||||
assertTrue("lint" in system.text)
|
||||
@@ -148,10 +148,10 @@ class ChatAgentTest {
|
||||
|
||||
// добавим сообщение, чтобы потом убедиться, что каскад сработал
|
||||
stores.messages.append(
|
||||
pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage(
|
||||
pw.binom.agentik.storage.MessageRecord.UserMessage(
|
||||
id = "m1",
|
||||
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),
|
||||
),
|
||||
)
|
||||
@@ -178,11 +178,11 @@ class ChatAgentTest {
|
||||
// user message записан в audit + working memory
|
||||
val msgs = stores.messages.listAll(conv.id)
|
||||
assertEquals(2, msgs.size)
|
||||
assertEquals("hi", (msgs[0] as pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage).content.let {
|
||||
(it[0] as pw.binom.agentik.standalone.persistence.Content.Text).body
|
||||
assertEquals("hi", (msgs[0] as pw.binom.agentik.storage.MessageRecord.UserMessage).content.let {
|
||||
(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 {
|
||||
(it[0] as pw.binom.agentik.standalone.persistence.Content.Text).body
|
||||
assertEquals("hello back", (msgs[1] as pw.binom.agentik.storage.MessageRecord.AssistantMessage).content.let {
|
||||
(it[0] as pw.binom.agentik.storage.Content.Text).body
|
||||
})
|
||||
}
|
||||
|
||||
@@ -305,8 +305,8 @@ class ChatAgentTest {
|
||||
|
||||
val msgs = stores.messages.listAll(conv.id)
|
||||
assertEquals(2, msgs.size)
|
||||
assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage>(msgs[0])
|
||||
val err = assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.Error>(msgs[1])
|
||||
assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
|
||||
val err = assertIs<pw.binom.agentik.storage.MessageRecord.Error>(msgs[1])
|
||||
assertEquals("boom from llm", err.message)
|
||||
|
||||
// backfill через getMessages (polling/reconnect) тоже видит ошибку
|
||||
@@ -338,7 +338,7 @@ class ChatAgentTest {
|
||||
// user сообщение в audit должно быть, assistant — нет (был отменён)
|
||||
val msgs = stores.messages.listAll(conv.id)
|
||||
assertEquals(1, msgs.size)
|
||||
assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage>(msgs[0])
|
||||
assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -88,7 +88,7 @@ class CompactionTest {
|
||||
}
|
||||
val wm = stores.workingMemory.list(conv.id)
|
||||
// Без 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")
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class CompactionTest {
|
||||
conv.send(listOf(ProtoContent.Text("first")))
|
||||
val wm = stores.workingMemory.list(conv.id)
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -118,10 +118,10 @@ class CompactionTest {
|
||||
assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold")
|
||||
// В working memory должна появиться Summary.
|
||||
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")
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -161,8 +161,8 @@ class CompactionTest {
|
||||
// Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов.
|
||||
// KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты.
|
||||
val userAssistantCount = wm.count {
|
||||
it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.User ||
|
||||
it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Assistant
|
||||
it.entry is pw.binom.agentik.storage.WorkingMemoryEntry.User ||
|
||||
it.entry is pw.binom.agentik.storage.WorkingMemoryEntry.Assistant
|
||||
}
|
||||
// Минимум 1 ход остаётся (KEEP_RECENT_TURNS).
|
||||
assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved")
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import pw.binom.agentik.standalone.persistence.MessageContext
|
||||
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 pw.binom.agentik.storage.MessageContext
|
||||
import pw.binom.agentik.storage.MessageOrigin.EVENT
|
||||
import pw.binom.agentik.storage.MessageOrigin.SYSTEM
|
||||
import pw.binom.agentik.storage.MessageOrigin.USER
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import kotlin.test.Test
|
||||
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.LlmConfig
|
||||
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 kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
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.decodeStringArray
|
||||
import pw.binom.agentik.standalone.persistence.encodeStringArray
|
||||
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
|
||||
|
||||
class ReflectionStoreTest {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -8,9 +8,8 @@ import kotlinx.serialization.json.JsonElement
|
||||
* Контекст инициации хода (кто/что и почему).
|
||||
*
|
||||
* Дубликат типа из `:proto` (`pw.binom.agentik.proto.MessageContext`):
|
||||
* живёт в `:standalone/persistence` чтобы не тащить `:proto` в слой
|
||||
* хранения данных. Маппинг между ними — в `MessageRecord.toProto()` /
|
||||
* `ProtoMessage.toStorage()`.
|
||||
* живёт в `:storage-core` чтобы не тащить `:proto` в слой хранения данных.
|
||||
* Маппинг между ними — в `MessageRecord.toProto()` / `ProtoMessage.toStorage()`.
|
||||
*
|
||||
* Используется:
|
||||
* - в `MessageRecord.UserMessage.context` — фиксируется в audit log;
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -33,7 +33,7 @@ data class TurnTokens(
|
||||
/**
|
||||
* Запись в таблице `message` (append-only audit) и `working_memory` (mutable view).
|
||||
*
|
||||
* Используется sealed-иерархию: подтипы `User`/`Assistant`/`ToolCall`/`ToolResult`
|
||||
* Использует sealed-иерархию: подтипы `User`/`Assistant`/`ToolCall`/`ToolResult`
|
||||
* живут и там, и там. `Summary`/`System` — только в `working_memory`
|
||||
* (синтетические строки, созданные при суммаризации или как system-prompt).
|
||||
*
|
||||
+10
-1
@@ -1,5 +1,6 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -39,9 +40,17 @@ interface MessageStore : AutoCloseable {
|
||||
/** Все сообщения диалога, отсортированные по `createdAt ASC` (для rebuild working memory). */
|
||||
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||
|
||||
/** Лайв-стрим новых сообщений (для SSE-подписчиков). По умолчанию — пустой. */
|
||||
fun events(): Flow<MessageEvent> = kotlinx.coroutines.flow.emptyFlow()
|
||||
|
||||
/**
|
||||
* Суммарная token-статистика по диалогу: input/output/turns. Один проход
|
||||
* по всем assistant-сообщениям. Дёшево (на практике < 1мс на SQLite).
|
||||
*/
|
||||
suspend fun tokenStats(conversationId: String): TokenStats
|
||||
}
|
||||
|
||||
sealed interface MessageEvent {
|
||||
val conversationId: String
|
||||
data class Appended(override val conversationId: String, val record: MessageRecord) : MessageEvent
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -27,7 +27,7 @@ private val bodyJson = Json {
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class MessageBodyPayload(
|
||||
data class MessageBodyPayload(
|
||||
val content: List<Content>,
|
||||
@SerialName("context")
|
||||
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
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
+6
-9
@@ -1,10 +1,7 @@
|
||||
package pw.binom.agentik.standalone.persistence
|
||||
package pw.binom.agentik.storage
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
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.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
@@ -19,7 +16,7 @@ class PayloadTest {
|
||||
@Test
|
||||
fun `user context roundtrips through MessageBodyPayload`() {
|
||||
val ctx = MessageContext(
|
||||
origin = USER,
|
||||
origin = MessageOrigin.USER,
|
||||
description = "irc PRIVMSG",
|
||||
sourceId = "irc:agentik",
|
||||
)
|
||||
@@ -36,7 +33,7 @@ class PayloadTest {
|
||||
@Test
|
||||
fun `event context roundtrips with metadata`() {
|
||||
val ctx = MessageContext(
|
||||
origin = EVENT,
|
||||
origin = MessageOrigin.EVENT,
|
||||
description = "scheduled cron morning-briefing",
|
||||
sourceId = "cron-42",
|
||||
metadata = buildJsonObject {
|
||||
@@ -46,7 +43,7 @@ class PayloadTest {
|
||||
)
|
||||
val encoded = encodeBodyPayload(listOf(Content.Text("wake up")), ctx)
|
||||
val decoded = decodeBodyPayload(encoded)
|
||||
assertEquals(EVENT, decoded.context?.origin)
|
||||
assertEquals(MessageOrigin.EVENT, decoded.context?.origin)
|
||||
assertEquals("scheduled cron morning-briefing", decoded.context?.description)
|
||||
assertEquals("cron-42", decoded.context?.sourceId)
|
||||
assertEquals("0 8 * * *", (decoded.context?.metadata?.let { (it as kotlinx.serialization.json.JsonObject)["rule"] } as? JsonPrimitive)?.content)
|
||||
@@ -73,10 +70,10 @@ class PayloadTest {
|
||||
|
||||
@Test
|
||||
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 decoded = decodeBodyPayload(encoded)
|
||||
assertEquals(SYSTEM, decoded.context?.origin)
|
||||
assertEquals(MessageOrigin.SYSTEM, decoded.context?.origin)
|
||||
assertEquals("agent startup greeting", decoded.context?.description)
|
||||
assertNull(decoded.context?.sourceId)
|
||||
assertNull(decoded.context?.metadata)
|
||||
Reference in New Issue
Block a user