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
+1
View File
@@ -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)
@@ -1,29 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Часть контента сообщения на уровне хранилища.
*
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
* идентичны, но даёт возможность заменить transport-протокол без миграции
* таблиц.
*
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
*/
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
data class Text(val body: String) : Content
@Serializable
@SerialName("image")
data class Image(val data: ByteArray, val mime: String) : Content {
override fun equals(other: Any?): Boolean =
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
}
}
@@ -1,14 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* Snapshot диалога. В таблице `conversation` хранится как есть.
*/
data class ConversationRecord(
val id: String,
val title: String?,
val isTemporal: Boolean,
val createdAt: Instant,
val updatedAt: Instant,
)
@@ -1,27 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* CRUD по таблице `conversation`.
*/
interface ConversationStore : AutoCloseable {
/** Создать или обновить snapshot диалога. */
suspend fun upsert(record: ConversationRecord)
/** Диалог по id, или `null`. */
suspend fun get(id: String): ConversationRecord?
/** Удалить диалог (вместе с его сообщениями и working memory). */
suspend fun delete(id: String): Boolean
/** Список диалогов, отсортированный по `updatedAt` DESC. */
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
suspend fun rename(id: String, title: String?): Instant?
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
suspend fun touch(id: String, now: Instant)
}
@@ -1,15 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlin.uuid.Uuid
/**
* Генератор id. Использует `kotlin.uuid.Uuid` из stdlib (KMP: jvm + native),
* чтобы не зависеть от `java.util.UUID` и подготовить код к linuxX64-сборке.
*
* Сохраняет формат `<prefix>-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` —
* `:server` его парсит как opaque string, без знания внутренней структуры.
*/
object Ids {
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
fun reflection(): String = new("refl")
}
@@ -1,41 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Контекст инициации хода (кто/что и почему).
*
* Дубликат типа из `:proto` (`pw.binom.agentik.proto.MessageContext`):
* живёт в `:standalone/persistence` чтобы не тащить `:proto` в слой
* хранения данных. Маппинг между ними — в `MessageRecord.toProto()` /
* `ProtoMessage.toStorage()`.
*
* Используется:
* - в `MessageRecord.UserMessage.context` — фиксируется в audit log;
* - в `WorkingMemoryEntry.User.context` — попадает в LLM-нагрузку
* как префикс к user-тексту (для не-USER origin'ов).
*
* Сериализация в `payload_json` (SQLite) — через kotlinx-serialization,
* формат snake_case для enum origin.
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@Serializable
data class MessageContext(
val origin: MessageOrigin,
val description: String? = null,
val sourceId: String? = null,
val metadata: JsonElement? = null,
)
@@ -1,144 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Token usage одного assistant turn'а: сколько токенов модель обработала
* на входе (system + history + tools + user message) и сколько сгенерировала
* (assistant text + tool calls + tool results, всё что LiteConversation
* добавила к истории за этот turn).
*
* `input` — снимок [LiteConversation.tokenCount] перед первым send() в turn'е
* (после подготовки user-сообщения). `output` — дельта после завершения turn'а
* (включая все tool loop итерации).
*
* Persisted в `message.payload_json` — никаких schema-миграций при добавлении
* полей. Optional: `null` для исторических сообщений или для бэкендов, не
* отдающих tokenCount (например off-line embedded LLM без контекст-счётчика).
*/
@Serializable
data class TurnTokens(
val input: Int,
val output: Int,
) {
val total: Int get() = input + output
init {
require(input >= 0) { "input tokens must be non-negative, got $input" }
require(output >= 0) { "output tokens must be non-negative, got $output" }
}
}
/**
* Запись в таблице `message` (append-only audit) и `working_memory` (mutable view).
*
* Используется sealed-иерархию: подтипы `User`/`Assistant`/`ToolCall`/`ToolResult`
* живут и там, и там. `Summary`/`System` — только в `working_memory`
* (синтетические строки, созданные при суммаризации или как system-prompt).
*
* Все подтипы несут [id] (UUID, стабильный между лайв-стримом Event и историей),
* [conversationId] и [createdAt].
*
* Поля, специфичные для подтипа, сериализуются в JSON в `payload_json`
* колонке SQLite — это даёт гибкость без миграций при добавлении полей.
*/
@Serializable
sealed interface MessageRecord {
val id: String
val conversationId: String
val createdAt: Instant
/** Подтип сообщения с телом из [Content]. */
@Serializable
sealed interface Body : MessageRecord {
val content: List<Content>
}
@Serializable
@SerialName("user")
data class UserMessage(
override val id: String,
override val conversationId: String,
override val content: List<Content>,
override val createdAt: Instant,
/**
* Контекст инициации хода: кто/что вызвал этот turn. `null` —
* обычное user-сообщение. См. [MessageContext].
*
* Persisted через `payload_json` SQLite (см. `Payload.kt`).
*/
val context: MessageContext? = null,
) : Body
@Serializable
@SerialName("assistant")
data class AssistantMessage(
override val id: String,
override val conversationId: String,
override val content: List<Content>,
override val createdAt: Instant,
/**
* Token usage этого turn'а: сколько input+output токенов обработала
* модель. Заполняется в [ChatConversation.runTurn] через
* `LiteConversation.tokenCount()` (до/после send).
*/
val tokens: TurnTokens? = null,
) : Body
@Serializable
@SerialName("tool_call")
data class ToolCall(
override val id: String,
override val conversationId: String,
val toolName: String,
val toolTitle: String?,
val toolArgsJson: String,
override val createdAt: Instant,
) : MessageRecord
@Serializable
@SerialName("tool_result")
data class ToolResult(
override val id: String,
override val conversationId: String,
val toolCallId: String,
val result: String?,
override val createdAt: Instant,
) : MessageRecord
/**
* Терминальная запись провалившегося хода. Только audit log
* (в working_memory не пишется — модель не должна видеть ошибки прошлых ходов).
*/
@Serializable
@SerialName("error")
data class Error(
override val id: String,
override val conversationId: String,
val message: String,
val code: String?,
override val createdAt: Instant,
) : MessageRecord
/** Синтетическое: суммаризация старого контекста. Только в working_memory. */
@Serializable
@SerialName("summary")
data class Summary(
override val id: String,
override val conversationId: String,
val text: String,
override val createdAt: Instant,
) : MessageRecord
/** Синтетическое: system-prompt, введённый при создании диалога. Только в working_memory. */
@Serializable
@SerialName("system")
data class System(
override val id: String,
override val conversationId: String,
val text: String,
override val createdAt: Instant,
) : MessageRecord
}
@@ -1,47 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* Суммарная статистика токенов диалога — aggregate по всем assistant-сообщениям
* в audit log. Делит input/output и считает число assistant-ходов.
*
* Используется:
* - В startup banner'е агента (см. `Main.kt` → "conversation stats").
* - На HTTP фасаде `/agentik/conversations/{id}/stats` (если будет endpoint).
* - В клиентских дашбордах для оценки cost.
*/
data class TokenStats(
val turns: Int,
val inputTokens: Long,
val outputTokens: Long,
) {
val totalTokens: Long get() = inputTokens + outputTokens
}
/**
* Append-only audit log сообщений (`message` table).
*
* Только `insert` и чтение. Никаких обновлений, никакого удаления (кроме
* каскадного удаления вместе с [ConversationStore.delete]).
*/
interface MessageStore : AutoCloseable {
/** Добавить запись в audit log. `conversationId` берётся из [MessageRecord.conversationId]. */
suspend fun append(record: MessageRecord)
/**
* Страница audit-сообщений диалога после [after] (UTC), отсортированная
* по `createdAt ASC`. Для первоначальной загрузки передай `Instant.DISTANT_PAST`.
*/
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/** Все сообщения диалога, отсортированные по `createdAt ASC` (для rebuild working memory). */
suspend fun listAll(conversationId: String): List<MessageRecord>
/**
* Суммарная token-статистика по диалогу: input/output/turns. Один проход
* по всем assistant-сообщениям. Дёшево (на практике < 1мс на SQLite).
*/
suspend fun tokenStats(conversationId: String): TokenStats
}
@@ -1,79 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
/**
* JSON-формат для тел user/assistant сообщений: список [Content], опционально
* с [MessageContext] (для user — кто инициировал ход) и [TurnTokens]
* (для assistant — сколько токенов стоил этот turn).
*
* Encoded-формат:
* ```
* {"content": [ ...Content ], "context": {...MessageContext?}, "tokens": {...TurnTokens?}}
* ```
*
* Backward compat: при чтении старых строк, где payload был просто
* `[ ... ]` (без обёртки), парсер падает на wrapper-формат и fallback'ит
* к `ListSerializer<Content>` — такие строки возвращаются с `context = null`,
* `tokens = null`.
*/
private val bodyJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Serializable
internal data class MessageBodyPayload(
val content: List<Content>,
@SerialName("context")
val context: MessageContext? = null,
/**
* Token usage для assistant (input + output). `null` для user-сообщений,
* для исторических assistant-сообщений без метрики и для бэкендов без
* tokenCount() (off-line модели).
*/
val tokens: TurnTokens? = null,
)
/**
* Сериализует тело user (или assistant) сообщения в JSON-строку для
* `payload_json` SQLite. Для user может нести [context] — кто инициировал ход;
* для assistant может нести [tokens] — token usage этого turn'а.
*/
fun encodeBodyPayload(
content: List<Content>,
context: MessageContext? = null,
tokens: TurnTokens? = null,
): String = bodyJson.encodeToString(
MessageBodyPayload.serializer(),
MessageBodyPayload(content = content, context = context, tokens = tokens),
)
/**
* Десериализует тело сообщения: возвращает тройку `(content, context, tokens)`.
* Контекст и токены — null если:
* - поля отсутствуют в новом формате;
* - payload в старом plain-array формате (миграция не нужна — fallback).
*/
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
data class BodyDecoded(
val content: List<Content>,
val context: MessageContext?,
val tokens: TurnTokens? = null,
)
private fun readPayload(json: String): BodyDecoded {
return try {
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
BodyDecoded(p.content, p.context, p.tokens)
} catch (e: kotlinx.serialization.SerializationException) {
// Старый формат: голый JSON-массив Content, без обёртки.
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
BodyDecoded(arr, null, null)
}
}
@@ -1,64 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Запись в working memory диалога: ровно то, что агент сейчас видит в
* LLM-контексте. Упорядочено по `order_idx` (заполняется в store при append).
*
* Sealed-иерархия: для v1 — `System` (синтетический system-prompt),
* `User`/`Assistant` (реплики с ссылкой на audit log через [sourceMessageId]).
* Суммаризация (для v2) добавит подтип `Summary`.
*/
@Serializable
sealed interface WorkingMemoryEntry {
/** Ссылка на исходное сообщение в audit log (`message.id`). `null` для синтетических строк. */
val sourceMessageId: String?
/** Синтетический system-prompt, добавляется при создании диалога. */
@Serializable
@SerialName("system")
data class System(val text: String) : WorkingMemoryEntry {
override val sourceMessageId: String? = null
}
/** Реплика пользователя. */
@Serializable
@SerialName("user")
data class User(
override val sourceMessageId: String,
val content: List<Content>,
/**
* Контекст инициации хода. Применяется при сборке `LiteConversation`:
* если `origin != USER`, текст префиксуется `[origin] description (sourceId=…)`,
* чтобы модель видела, что её разбудил не пользователь.
* `null` = обычное user-сообщение.
*/
val context: MessageContext? = null,
) : WorkingMemoryEntry
/** Реплика ассистента. */
@Serializable
@SerialName("assistant")
data class Assistant(
override val sourceMessageId: String,
val content: List<Content>,
) : WorkingMemoryEntry
/**
* Синтетический блок: суммаризация старых ходов, сгенерированная при
* compaction'е working memory. Не имеет ссылки на конкретное сообщение
* в audit log — это наша собственная интерпретация контекста.
*/
@Serializable
@SerialName("summary")
data class Summary(
val text: String,
/** Ходы, которые были свёрнуты в этот summary (диапазон order_idx в виде меты). */
val coversUpToOrderIdx: Long? = null,
) : WorkingMemoryEntry {
override val sourceMessageId: String? = null
}
}
@@ -1,58 +0,0 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* Одна строка `working_memory` таблицы (внутреннее представление store).
*
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
* Агент не должен с этим типом работать напрямую — он работает с
* [WorkingMemoryEntry] через [WorkingMemoryStore].
*/
data class WorkingMemoryRow(
val id: String,
val conversationId: String,
val orderIdx: Long,
val sourceMessageId: String?,
val entry: WorkingMemoryEntry,
val createdAt: Instant,
)
/**
* Мутируемое представление LLM-контекста диалога (`working_memory` table).
*
* Аудит-лог — [MessageStore], неизменный; здесь — ровно то, что агент сейчас
* «видит»: системный промпт + реплики + (опционально) суммаризации.
* Строки упорядочены по `order_idx` ASC; `source_message_id` NULL указывает
* на синтетические строки (System, а в v2 — Summary).
*
* Суммаризация / чистка — один атомарный вызов [compact].
*/
interface WorkingMemoryStore : AutoCloseable {
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
/** Все строки working memory диалога в порядке отправки. */
suspend fun list(conversationId: String): List<WorkingMemoryRow>
/** Очистить working memory диалога (используется при reset/rebuild). */
suspend fun clear(conversationId: String)
/**
* Атомарная суммаризация: удаляет все строки с `order_idx` в диапазоне
* `[dropFromOrderIdx, +∞)`. Если [summaryText] непустое — вместо удалённых
* строк вставляется одна синтетическая [WorkingMemoryEntry.Summary]
* с этим текстом и `order_idx = max(old order_idx after delete) + 1`
* (т.е. summary становится хвостом working memory).
*
* Если [summaryText] == null — работает как «отрезать хвост» (v1 поведение).
*
* Возвращает новый максимальный `order_idx` после операции.
*/
suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String? = null,
): Long
}
@@ -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,
@@ -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
/**
@@ -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
@@ -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,
@@ -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].
@@ -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
/**
@@ -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].
@@ -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")
@@ -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")
@@ -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,84 +0,0 @@
package pw.binom.agentik.standalone.persistence
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
import kotlin.test.assertTrue
/**
* Тесты payload-формата: контекст инициации хода персистится через
* `payload_json` SQLite, старый plain-array формат читается без потерь.
*/
class PayloadTest {
@Test
fun `user context roundtrips through MessageBodyPayload`() {
val ctx = MessageContext(
origin = USER,
description = "irc PRIVMSG",
sourceId = "irc:agentik",
)
val encoded = encodeBodyPayload(listOf(Content.Text("hi")), ctx)
// новый формат: wrapper-объект с полем context
assertTrue(encoded.startsWith("{"), "expected wrapped object, got: $encoded")
assertTrue(encoded.contains("\"context\""), "expected context field, got: $encoded")
val decoded = decodeBodyPayload(encoded)
assertEquals(listOf(Content.Text("hi")), decoded.content)
assertEquals(ctx, decoded.context)
}
@Test
fun `event context roundtrips with metadata`() {
val ctx = MessageContext(
origin = EVENT,
description = "scheduled cron morning-briefing",
sourceId = "cron-42",
metadata = buildJsonObject {
put("scheduledAt", JsonPrimitive("2026-09-14T08:00:00Z"))
put("rule", JsonPrimitive("0 8 * * *"))
},
)
val encoded = encodeBodyPayload(listOf(Content.Text("wake up")), ctx)
val decoded = decodeBodyPayload(encoded)
assertEquals(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)
}
@Test
fun `null context produces wrapper without context field`() {
val encoded = encodeBodyPayload(listOf(Content.Text("hello")))
assertTrue(encoded.contains("\"content\""), "expected content field, got: $encoded")
val decoded = decodeBodyPayload(encoded)
assertEquals(listOf(Content.Text("hello")), decoded.content)
assertNull(decoded.context)
}
@Test
fun `legacy plain-array payload still decodes (backward compat)`() {
val legacy = "[" +
"""{"type":"text","body":"old message"}""" +
"]"
val decoded = decodeBodyPayload(legacy)
assertEquals(listOf(Content.Text("old message")), decoded.content)
assertNull(decoded.context)
}
@Test
fun `system context with description only roundtrips`() {
val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting")
val encoded = encodeBodyPayload(listOf(Content.Text("boot")), ctx)
val decoded = decodeBodyPayload(encoded)
assertEquals(SYSTEM, decoded.context?.origin)
assertEquals("agent startup greeting", decoded.context?.description)
assertNull(decoded.context?.sourceId)
assertNull(decoded.context?.metadata)
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.persistence
package pw.binom.agentik.storage
import kotlin.test.Test
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 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.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,4 +1,4 @@
package pw.binom.agentik.standalone.persistence
package pw.binom.agentik.storage
import kotlin.test.Test
import kotlin.test.assertEquals