standalone: ChatAgent принимает StorageBundle вместо SqliteStores

Финальный swap — ChatAgent/ChatConversation теперь работают через абстрактный
StorageBundle (pw.binom.agentik.storage), а не через конкретный SqliteStores.
Подготовка к Android-портированию (там будет :storage-android вместо
:storage-sqlite).

Изменения:
- SqliteStores.asBundle() — convenience для превращения конкретного
  SQLite-импла в StorageBundle
- ChatAgent(private val storage: StorageBundle) — было stores: SqliteStores
- ChatConversation(private val storage: StorageBundle) — то же
- Main.kt, DebugRoutes.kt — вызовы обновлены, используется .asBundle()
- Все 5 тестовых файлов с ChatAgent(... stores = ...) — обновлены на
  ChatAgent(... storage = ...asBundle())
- StorageBundle : AutoCloseable — закрывает все 4 store'а; в тестах
  tearDown { storage.close() }

Конфиг не менялся: toolsets остаётся emptyList() по умолчанию (полная
невидимость механики тулсетов для модели). Подключение тулсетов — opt-in
через параметр ChatAgent(toolsets = ...) для будущего e2e-теста в post-implementation.

Tests: 340/340 green. Fatjar 240 MB. Без регрессий.
This commit is contained in:
2026-09-15 15:25:36 +03:00
parent 1dc5552f98
commit 9ee942428d
9 changed files with 103 additions and 91 deletions
@@ -14,7 +14,7 @@ import pw.binom.agentik.standalone.agent.ChatConversation
import pw.binom.agentik.standalone.agent.LlmReflector import pw.binom.agentik.standalone.agent.LlmReflector
import pw.binom.agentik.standalone.agent.SkillMiner import pw.binom.agentik.standalone.agent.SkillMiner
import pw.binom.agentik.standalone.agent.memory.Curator import pw.binom.agentik.standalone.agent.memory.Curator
import pw.binom.agentik.storage.sqlite.SqliteStores import pw.binom.agentik.storage.StorageBundle
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
/** /**
@@ -33,7 +33,7 @@ import pw.binom.agentik.skills.SkillStore
*/ */
internal fun Route.debugRoutes( internal fun Route.debugRoutes(
agent: Agent, agent: Agent,
stores: SqliteStores, storage: pw.binom.agentik.storage.StorageBundle,
reflector: LlmReflector?, reflector: LlmReflector?,
skillMiner: SkillMiner?, skillMiner: SkillMiner?,
skillStore: SkillStore?, skillStore: SkillStore?,
@@ -44,14 +44,14 @@ internal fun Route.debugRoutes(
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest) ?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
val minerReflector = reflector val minerReflector = reflector
if (minerReflector == null) return@post call.respondText("reflection disabled", status = HttpStatusCode.NotFound) if (minerReflector == null) return@post call.respondText("reflection disabled", status = HttpStatusCode.NotFound)
val turns = recentTurns(stores, convId, maxTurns = 6) val turns = recentTurns(storage, convId, maxTurns = 6)
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound) if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
val reflection = minerReflector.reflect(turns) val reflection = minerReflector.reflect(turns)
if (reflection == null) { if (reflection == null) {
call.respondText("""{"reflected":false,"reason":"unparseable LLM reply"}""", contentType = ContentType.Application.Json) call.respondText("""{"reflected":false,"reason":"unparseable LLM reply"}""", contentType = ContentType.Application.Json)
} else { } else {
val stamped = reflection.copy(conversationId = convId) val stamped = reflection.copy(conversationId = convId)
stores.reflections.insert(stamped) storage.reflectionStore.insert(stamped)
call.respondText( call.respondText(
buildJsonObject { buildJsonObject {
put("reflected", true) put("reflected", true)
@@ -71,7 +71,7 @@ internal fun Route.debugRoutes(
val miner = skillMiner val miner = skillMiner
val store = skillStore val store = skillStore
if (miner == null || store == null) return@post call.respondText("skill mining disabled", status = HttpStatusCode.NotFound) if (miner == null || store == null) return@post call.respondText("skill mining disabled", status = HttpStatusCode.NotFound)
val turns = recentTurns(stores, convId, maxTurns = miner.maxTurns) val turns = recentTurns(storage, convId, maxTurns = miner.maxTurns)
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound) if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
val mined = miner.mine(turns, store.catalog.skills) val mined = miner.mine(turns, store.catalog.skills)
for (s in mined) store.upsert(s) for (s in mined) store.upsert(s)
@@ -110,7 +110,7 @@ internal fun Route.debugRoutes(
get("/debug/tokens") { get("/debug/tokens") {
val convId = call.parameters["conversationId"] val convId = call.parameters["conversationId"]
?: return@get call.respondText("conversationId required", status = HttpStatusCode.BadRequest) ?: return@get call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
val stats = stores.messages.tokenStats(convId) val stats = storage.messageStore.tokenStats(convId)
val json = buildJsonObject { val json = buildJsonObject {
put("conversationId", convId) put("conversationId", convId)
put("turns", stats.turns.toString()) put("turns", stats.turns.toString())
@@ -127,8 +127,8 @@ internal fun Route.debugRoutes(
* (для debug-триггеров reflector/miner; та же логика, что у хуков * (для debug-триггеров reflector/miner; та же логика, что у хуков
* [ChatConversation]). * [ChatConversation]).
*/ */
internal suspend fun recentTurns(stores: SqliteStores, conversationId: String, maxTurns: Int): List<ConversationTurn> { internal suspend fun recentTurns(storage: pw.binom.agentik.storage.StorageBundle, conversationId: String, maxTurns: Int): List<ConversationTurn> {
val rows = stores.workingMemory.list(conversationId) val rows = storage.workingMemoryStore.list(conversationId)
val pairs = mutableListOf<ConversationTurn>() val pairs = mutableListOf<ConversationTurn>()
var pendingUser: String? = null var pendingUser: String? = null
for (row in rows) { for (row in rows) {
@@ -58,7 +58,7 @@ fun main() {
val config = AgentikConfig.fromEnv() val config = AgentikConfig.fromEnv()
val llm = config.llm.createLlm() val llm = config.llm.createLlm()
val stores = SqliteStores.open(dbPath = config.dbPath) val storage = SqliteStores.open(dbPath = config.dbPath).asBundle()
val mcpRegistry = McpRegistry.fromConfig(config.mcp) val mcpRegistry = McpRegistry.fromConfig(config.mcp)
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём // Хранилище скилов: если skillsDir задан, читаем каталог + создаём
@@ -179,12 +179,12 @@ fun main() {
if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null
val recentReflections: List<pw.binom.agentik.storage.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) storage.reflectionStore.listRecent(config.reflectionTopK)
} else emptyList() } else emptyList()
val agent = ChatAgent( val agent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = llm, llm = llm,
llmConfig = config.llm, llmConfig = config.llm,
tools = mcpRegistry.namedTools, tools = mcpRegistry.namedTools,
@@ -222,7 +222,7 @@ fun main() {
if (config.debugEndpoints) { if (config.debugEndpoints) {
debugRoutes( debugRoutes(
agent = agent, agent = agent,
stores = stores, storage = storage,
reflector = reflector, reflector = reflector,
skillMiner = skillMiner, skillMiner = skillMiner,
skillStore = skillStore, skillStore = skillStore,
@@ -260,14 +260,14 @@ fun main() {
// Token stats по существующим диалогам (агрегат на старте — каждая запись // Token stats по существующим диалогам (агрегат на старте — каждая запись
// парсится из payload_json, ну >100 turns и БД приличная — но в рамках // парсится из payload_json, ну >100 turns и БД приличная — но в рамках
// стартапа это терпимо). // стартапа это терпимо).
val existingConvs = kotlinx.coroutines.runBlocking { stores.conversations.list(offset = 0, limit = 1000) } val existingConvs = kotlinx.coroutines.runBlocking { storage.conversationStore.list(offset = 0, limit = 1000) }
if (existingConvs.isNotEmpty()) { if (existingConvs.isNotEmpty()) {
var totalTurns = 0 var totalTurns = 0
var totalIn = 0L var totalIn = 0L
var totalOut = 0L var totalOut = 0L
for (c in existingConvs) { for (c in existingConvs) {
if (c.isTemporal) continue if (c.isTemporal) continue
val s = kotlinx.coroutines.runBlocking { stores.messages.tokenStats(c.id) } val s = kotlinx.coroutines.runBlocking { storage.messageStore.tokenStats(c.id) }
totalTurns += s.turns totalTurns += s.turns
totalIn += s.inputTokens totalIn += s.inputTokens
totalOut += s.outputTokens totalOut += s.outputTokens
@@ -279,7 +279,7 @@ fun main() {
Runtime.getRuntime().addShutdownHook(Thread { Runtime.getRuntime().addShutdownHook(Thread {
agent.close() agent.close()
mcpRegistry.close() mcpRegistry.close()
stores.close() storage.close()
llm.close() llm.close()
memorySystem?.close() memorySystem?.close()
}) })
@@ -19,7 +19,7 @@ import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.storage.ConversationRecord import pw.binom.agentik.storage.ConversationRecord
import pw.binom.agentik.storage.Reflection import pw.binom.agentik.storage.Reflection
import pw.binom.agentik.storage.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.sqlite.SqliteStores import pw.binom.agentik.storage.StorageBundle
import pw.binom.agentik.toolsets.EnableToolsetTool import pw.binom.agentik.toolsets.EnableToolsetTool
import pw.binom.agentik.toolsets.DisableToolsetTool import pw.binom.agentik.toolsets.DisableToolsetTool
import pw.binom.agentik.toolsets.SystemPromptToolsetSection import pw.binom.agentik.toolsets.SystemPromptToolsetSection
@@ -51,7 +51,7 @@ import kotlin.time.Instant
*/ */
class ChatAgent( class ChatAgent(
override val id: String, override val id: String,
private val stores: SqliteStores, private val storage: pw.binom.agentik.storage.StorageBundle,
private val llm: LiteLlm, private val llm: LiteLlm,
private val llmConfig: LlmConfig, private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = emptyList(), private val tools: List<NamedTool> = emptyList(),
@@ -211,8 +211,8 @@ class ChatAgent(
// и не переживают рестарт агента (см. Memory #3709). // и не переживают рестарт агента (см. Memory #3709).
if (!temp) { if (!temp) {
runBlocking { runBlocking {
stores.conversations.upsert(rec) storage.conversationStore.upsert(rec)
stores.workingMemory.append( storage.workingMemoryStore.append(
conversationId = id, conversationId = id,
entry = WorkingMemoryEntry.System(text = systemPrompt), entry = WorkingMemoryEntry.System(text = systemPrompt),
now = now, now = now,
@@ -221,7 +221,7 @@ class ChatAgent(
} }
val conv = ChatConversation( val conv = ChatConversation(
record = rec, record = rec,
stores = stores, storage = storage,
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
tools = allTools, tools = allTools,
@@ -232,7 +232,7 @@ class ChatAgent(
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflectionStore = stores.reflections, reflectionStore = storage.reflectionStore,
reflector = reflector, reflector = reflector,
reflectionInterval = reflectionInterval, reflectionInterval = reflectionInterval,
skillMiner = skillMiner, skillMiner = skillMiner,
@@ -248,7 +248,7 @@ class ChatAgent(
override suspend fun getConversation(id: String): ProtoConversation? { override suspend fun getConversation(id: String): ProtoConversation? {
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it } liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
val rec = stores.conversations.get(id) ?: return null val rec = storage.conversationStore.get(id) ?: return null
return newConversation(rec).also { return newConversation(rec).also {
liveLock.withLock { live[id] = it } liveLock.withLock { live[id] = it }
} }
@@ -257,13 +257,13 @@ class ChatAgent(
override suspend fun deleteConversation(id: String): Boolean { override suspend fun deleteConversation(id: String): Boolean {
val conv = liveLock.withLock { live.remove(id) } val conv = liveLock.withLock { live.remove(id) }
conv?.close() conv?.close()
val ok = stores.conversations.delete(id) val ok = storage.conversationStore.delete(id)
if (ok) agentEvents.tryEmit(AgentEvent.Deleted(date = now(), id = id)) if (ok) agentEvents.tryEmit(AgentEvent.Deleted(date = now(), id = id))
return ok return ok
} }
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> = override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
stores.conversations.list(offset = offset, limit = limit).map { rec -> storage.conversationStore.list(offset = offset, limit = limit).map { rec ->
liveLock.withLock { live[rec.id] } liveLock.withLock { live[rec.id] }
?: newConversation(rec).also { ?: newConversation(rec).also {
liveLock.withLock { live[rec.id] = it } liveLock.withLock { live[rec.id] = it }
@@ -272,7 +272,7 @@ class ChatAgent(
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation( private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
record = rec, record = rec,
stores = stores, storage = storage,
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
tools = allTools, tools = allTools,
@@ -283,7 +283,7 @@ class ChatAgent(
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflectionStore = stores.reflections, reflectionStore = storage.reflectionStore,
reflector = reflector, reflector = reflector,
reflectionInterval = reflectionInterval, reflectionInterval = reflectionInterval,
skillMiner = skillMiner, skillMiner = skillMiner,
@@ -38,7 +38,6 @@ import pw.binom.agentik.storage.MessageStore
import pw.binom.agentik.storage.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.WorkingMemoryRow import pw.binom.agentik.storage.WorkingMemoryRow
import pw.binom.agentik.storage.WorkingMemoryStore import pw.binom.agentik.storage.WorkingMemoryStore
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
@@ -71,7 +70,7 @@ import kotlin.time.Instant
*/ */
class ChatConversation( class ChatConversation(
record: ConversationRecord, record: ConversationRecord,
private val stores: SqliteStores, private val storage: pw.binom.agentik.storage.StorageBundle,
private val llm: LiteLlm, private val llm: LiteLlm,
private val systemPrompt: String, private val systemPrompt: String,
private val tools: List<NamedTool> = emptyList(), private val tools: List<NamedTool> = emptyList(),
@@ -152,9 +151,9 @@ class ChatConversation(
override val title: String? get() = record.title override val title: String? get() = record.title
override val updatedAt: Instant get() = record.updatedAt override val updatedAt: Instant get() = record.updatedAt
private val conversationStore: ConversationStore get() = stores.conversations private val conversationStore: ConversationStore get() = storage.conversationStore
private val messageStore: MessageStore get() = stores.messages private val messageStore: MessageStore get() = storage.messageStore
private val workingMemory: WorkingMemoryStore get() = stores.workingMemory private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore
private val toolsByName: Map<String, NamedTool> = tools.associateBy { it.name } private val toolsByName: Map<String, NamedTool> = tools.associateBy { it.name }
@@ -38,28 +38,28 @@ import kotlin.time.Instant
class ChatAgentTest { class ChatAgentTest {
private lateinit var stores: SqliteStores private lateinit var storage: pw.binom.agentik.storage.StorageBundle
private lateinit var fakeLlm: FakeLiteLlm private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest @BeforeTest
fun setup() { fun setup() {
stores = SqliteStores.inMemory() storage = SqliteStores.inMemory().asBundle()
fakeLlm = FakeLiteLlm() fakeLlm = FakeLiteLlm()
} }
@AfterTest @AfterTest
fun tearDown() { fun tearDown() {
stores.close() storage.close()
} }
private fun newAgent( private fun newAgent(
stores: SqliteStores = this.stores, storage: pw.binom.agentik.storage.StorageBundle = this.storage,
llm: LiteLlm = this.fakeLlm, llm: LiteLlm = this.fakeLlm,
tools: List<NamedTool> = emptyList(), tools: List<NamedTool> = emptyList(),
skills: SkillCatalog = SkillCatalog.EMPTY, skills: SkillCatalog = SkillCatalog.EMPTY,
): ChatAgent = ChatAgent( ): ChatAgent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = llm, llm = llm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = LlmBackend.OPENAI, backend = LlmBackend.OPENAI,
@@ -75,7 +75,7 @@ class ChatAgentTest {
val agent = newAgent() val agent = newAgent()
val conv = agent.createConversation(temp = false) as ChatConversation val conv = agent.createConversation(temp = false) as ChatConversation
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
assertEquals(1, wm.size) assertEquals(1, wm.size)
val first = wm[0] val first = wm[0]
val sysEntry = first.entry as pw.binom.agentik.storage.WorkingMemoryEntry.System val sysEntry = first.entry as pw.binom.agentik.storage.WorkingMemoryEntry.System
@@ -90,7 +90,7 @@ class ChatAgentTest {
val agent = newAgent(skills = skills) val agent = newAgent(skills = skills)
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 = storage.workingMemoryStore.list(conv.id).first().entry
as pw.binom.agentik.storage.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)
@@ -147,7 +147,7 @@ class ChatAgentTest {
val id = conv.id val id = conv.id
// добавим сообщение, чтобы потом убедиться, что каскад сработал // добавим сообщение, чтобы потом убедиться, что каскад сработал
stores.messages.append( storage.messageStore.append(
pw.binom.agentik.storage.MessageRecord.UserMessage( pw.binom.agentik.storage.MessageRecord.UserMessage(
id = "m1", id = "m1",
conversationId = id, conversationId = id,
@@ -157,8 +157,8 @@ class ChatAgentTest {
) )
assertTrue(agent.deleteConversation(id)) assertTrue(agent.deleteConversation(id))
assertNull(agent.getConversation(id)) assertNull(agent.getConversation(id))
assertNull(stores.conversations.get(id)) assertNull(storage.conversationStore.get(id))
assertEquals(emptyList(), stores.messages.listAll(id)) assertEquals(emptyList(), storage.messageStore.listAll(id))
} }
@Test @Test
@@ -176,7 +176,7 @@ class ChatAgentTest {
conv.send(listOf(Content.Text("hi"))) conv.send(listOf(Content.Text("hi")))
// user message записан в audit + working memory // user message записан в audit + working memory
val msgs = stores.messages.listAll(conv.id) val msgs = storage.messageStore.listAll(conv.id)
assertEquals(2, msgs.size) assertEquals(2, msgs.size)
assertEquals("hi", (msgs[0] as pw.binom.agentik.storage.MessageRecord.UserMessage).content.let { assertEquals("hi", (msgs[0] as pw.binom.agentik.storage.MessageRecord.UserMessage).content.let {
(it[0] as pw.binom.agentik.storage.Content.Text).body (it[0] as pw.binom.agentik.storage.Content.Text).body
@@ -200,10 +200,10 @@ class ChatAgentTest {
conv2.send(listOf(Content.Text("second user"))) conv2.send(listOf(Content.Text("second user")))
// Первая беседа должна иметь только свою систему + 1 user + 1 assistant // Первая беседа должна иметь только свою систему + 1 user + 1 assistant
val wm1 = stores.workingMemory.list(conv1.id) val wm1 = storage.workingMemoryStore.list(conv1.id)
assertEquals(3, wm1.size) assertEquals(3, wm1.size)
// Вторая беседа — только своё // Вторая беседа — только своё
val wm2 = stores.workingMemory.list(conv2.id) val wm2 = storage.workingMemoryStore.list(conv2.id)
assertEquals(3, wm2.size) assertEquals(3, wm2.size)
} }
@@ -237,12 +237,12 @@ class ChatAgentTest {
fakeLlm.reply = "first reply" fakeLlm.reply = "first reply"
conv.send(listOf(Content.Text("first user"))) conv.send(listOf(Content.Text("first user")))
// первый turn: WM = [system, user, assistant] // первый turn: WM = [system, user, assistant]
assertEquals(3, stores.workingMemory.list(conv.id).size) assertEquals(3, storage.workingMemoryStore.list(conv.id).size)
fakeLlm.reply = "second reply" fakeLlm.reply = "second reply"
conv.send(listOf(Content.Text("second user"))) conv.send(listOf(Content.Text("second user")))
// второй turn: WM должен вырасти до [system, user, assistant, user, assistant] // второй turn: WM должен вырасти до [system, user, assistant, user, assistant]
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
assertEquals(5, wm.size) assertEquals(5, wm.size)
// Длинно-живущий LiteConversation: один на ChatConversation, история // Длинно-живущий LiteConversation: один на ChatConversation, история
// накапливается через sendStreamContents, без пересоздания. // накапливается через sendStreamContents, без пересоздания.
@@ -303,7 +303,7 @@ class ChatAgentTest {
assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events") assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events")
assertTrue(events.any { it is ProtoEvent.End }, "events=$events") assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
val msgs = stores.messages.listAll(conv.id) val msgs = storage.messageStore.listAll(conv.id)
assertEquals(2, msgs.size) assertEquals(2, msgs.size)
assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0]) assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
val err = assertIs<pw.binom.agentik.storage.MessageRecord.Error>(msgs[1]) val err = assertIs<pw.binom.agentik.storage.MessageRecord.Error>(msgs[1])
@@ -336,7 +336,7 @@ class ChatAgentTest {
sendJob.join() sendJob.join()
// user сообщение в audit должно быть, assistant — нет (был отменён) // user сообщение в audit должно быть, assistant — нет (был отменён)
val msgs = stores.messages.listAll(conv.id) val msgs = storage.messageStore.listAll(conv.id)
assertEquals(1, msgs.size) assertEquals(1, msgs.size)
assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0]) assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
} }
@@ -344,12 +344,12 @@ class ChatAgentTest {
@Test @Test
fun `temp conversation is not persisted across agent instances`() = runTest { fun `temp conversation is not persisted across agent instances`() = runTest {
// Поднимаем file-backed БД, создаём temp-беседу // Поднимаем file-backed БД, создаём temp-беседу
stores.close() storage.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
stores = SqliteStores.open(dbPath) storage = SqliteStores.open(dbPath).asBundle()
val agent1 = ChatAgent( val agent1 = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = FakeLiteLlm().also { fakeLlm = it }, llm = FakeLiteLlm().also { fakeLlm = it },
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
@@ -362,11 +362,11 @@ class ChatAgentTest {
assertNotNull(agent1.getConversation(tempId)) assertNotNull(agent1.getConversation(tempId))
// Переоткрываем БД — temp-беседа не должна пережить рестарт // Переоткрываем БД — temp-беседа не должна пережить рестарт
stores.close() storage.close()
stores = SqliteStores.open(dbPath) storage = SqliteStores.open(dbPath).asBundle()
val agent2 = ChatAgent( val agent2 = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = fakeLlm, llm = fakeLlm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
@@ -380,12 +380,12 @@ class ChatAgentTest {
@Test @Test
fun `non-temp conversation persists across agent instances`() = runTest { fun `non-temp conversation persists across agent instances`() = runTest {
stores.close() storage.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db") val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
stores = SqliteStores.open(dbPath) storage = SqliteStores.open(dbPath).asBundle()
val agent1 = ChatAgent( val agent1 = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = FakeLiteLlm().also { fakeLlm = it }, llm = FakeLiteLlm().also { fakeLlm = it },
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
@@ -396,11 +396,11 @@ class ChatAgentTest {
val conv = agent1.createConversation(temp = false) val conv = agent1.createConversation(temp = false)
val id = conv.id val id = conv.id
stores.close() storage.close()
stores = SqliteStores.open(dbPath) storage = SqliteStores.open(dbPath).asBundle()
val agent2 = ChatAgent( val agent2 = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = fakeLlm, llm = fakeLlm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI, backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
@@ -39,11 +39,11 @@ class ChatAgentToolsetsTest {
private fun newAgent( private fun newAgent(
toolsets: List<ToolsetContribution> = emptyList(), toolsets: List<ToolsetContribution> = emptyList(),
): Pair<ChatAgent, SqliteStores> { ): Pair<ChatAgent, pw.binom.agentik.storage.StorageBundle> {
val stores = SqliteStores.inMemory() val storage = SqliteStores.inMemory().asBundle()
val agent = ChatAgent( val agent = ChatAgent(
id = "test-agent", id = "test-agent",
stores = stores, storage = storage,
llm = stubLlm(), llm = stubLlm(),
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = pw.binom.agentik.standalone.llm.LlmBackend.GOOGLE, backend = pw.binom.agentik.standalone.llm.LlmBackend.GOOGLE,
@@ -52,12 +52,12 @@ class ChatAgentToolsetsTest {
), ),
toolsets = toolsets, toolsets = toolsets,
) )
return agent to stores return agent to storage
} }
@Test @Test
fun `default (empty toolsets) does not register enable or disable tools`() { fun `default (empty toolsets) does not register enable or disable tools`() {
val (agent, stores) = newAgent() val (agent, storage) = newAgent()
try { try {
// Проверяем через allTools-эквивалент: вызываем enable_toolset // Проверяем через allTools-эквивалент: вызываем enable_toolset
// и ожидаем что он не найден — это значит тул не зарегистрирован. // и ожидаем что он не найден — это значит тул не зарегистрирован.
@@ -66,7 +66,7 @@ class ChatAgentToolsetsTest {
val prompt = (conv as ChatConversation).let { it.systemPromptForTest() } val prompt = (conv as ChatConversation).let { it.systemPromptForTest() }
assertFalse(prompt.contains("## Toolsets"), "toolsets section must NOT appear when toolsets empty") assertFalse(prompt.contains("## Toolsets"), "toolsets section must NOT appear when toolsets empty")
assertFalse(prompt.contains("enable_toolset"), "enable_toolset must NOT be mentioned when toolsets empty") assertFalse(prompt.contains("enable_toolset"), "enable_toolset must NOT be mentioned when toolsets empty")
} finally { stores.close() } } finally { storage.close() }
} }
@Test @Test
@@ -83,7 +83,7 @@ class ChatAgentToolsetsTest {
tools = listOf(ToolsetContribution.ToolEntry("fetch_url", stubTool("fetch_url"))), tools = listOf(ToolsetContribution.ToolEntry("fetch_url", stubTool("fetch_url"))),
), ),
) )
val (agent, stores) = newAgent(toolsets = toolsets) val (agent, storage) = newAgent(toolsets = toolsets)
try { try {
val conv = agent.createConversation(temp = true) as ChatConversation val conv = agent.createConversation(temp = true) as ChatConversation
val prompt = conv.systemPromptForTest() val prompt = conv.systemPromptForTest()
@@ -92,7 +92,7 @@ class ChatAgentToolsetsTest {
assertTrue(prompt.contains("- web — fetch and parse web pages")) assertTrue(prompt.contains("- web — fetch and parse web pages"))
// Оба тула — в списке allTools (через system prompt проверяем только prompt; // Оба тула — в списке allTools (через system prompt проверяем только prompt;
// наличие тулов проверим отдельно — см. ниже). // наличие тулов проверим отдельно — см. ниже).
} finally { stores.close() } } finally { storage.close() }
} }
@Test @Test
@@ -105,7 +105,7 @@ class ChatAgentToolsetsTest {
tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)),
), ),
) )
val (agent, stores) = newAgent(toolsets = toolsets) val (agent, storage) = newAgent(toolsets = toolsets)
try { try {
val conv = agent.createConversation(temp = true) as ChatConversation val conv = agent.createConversation(temp = true) as ChatConversation
@@ -120,7 +120,7 @@ class ChatAgentToolsetsTest {
conv.dispatchToolForTest("resize_image", "{}") conv.dispatchToolForTest("resize_image", "{}")
} }
assertEquals("image-resized-100x100", resized) assertEquals("image-resized-100x100", resized)
} finally { stores.close() } } finally { storage.close() }
} }
@Test @Test
@@ -133,7 +133,7 @@ class ChatAgentToolsetsTest {
tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)),
), ),
) )
val (agent, stores) = newAgent(toolsets = toolsets) val (agent, storage) = newAgent(toolsets = toolsets)
try { try {
val conv = agent.createConversation(temp = true) as ChatConversation val conv = agent.createConversation(temp = true) as ChatConversation
@@ -142,7 +142,7 @@ class ChatAgentToolsetsTest {
conv.dispatchToolForTest("resize_image", "{}") conv.dispatchToolForTest("resize_image", "{}")
} }
assertEquals("auto-activated-and-ran", result) assertEquals("auto-activated-and-ran", result)
} finally { stores.close() } } finally { storage.close() }
} }
@Test @Test
@@ -158,7 +158,7 @@ class ChatAgentToolsetsTest {
tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))), tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))),
), ),
) )
val (agent, stores) = newAgent(toolsets = toolsets) val (agent, storage) = newAgent(toolsets = toolsets)
try { try {
val conv = agent.createConversation(temp = true) as ChatConversation val conv = agent.createConversation(temp = true) as ChatConversation
@@ -174,7 +174,7 @@ class ChatAgentToolsetsTest {
conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""") conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""")
} }
assertEquals("Toolset 'media' deactivated.", disabledAgain) assertEquals("Toolset 'media' deactivated.", disabledAgain)
} finally { stores.close() } } finally { storage.close() }
} }
} }
@@ -39,18 +39,18 @@ import kotlinx.coroutines.flow.asSharedFlow
*/ */
class CompactionTest { class CompactionTest {
private lateinit var stores: SqliteStores private lateinit var storage: pw.binom.agentik.storage.StorageBundle
private lateinit var fakeLlm: FakeLiteLlm private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest @BeforeTest
fun setup() { fun setup() {
stores = SqliteStores.inMemory() storage = SqliteStores.inMemory().asBundle()
fakeLlm = FakeLiteLlm() fakeLlm = FakeLiteLlm()
} }
@AfterTest @AfterTest
fun tearDown() { fun tearDown() {
stores.close() storage.close()
} }
private fun newAgent( private fun newAgent(
@@ -62,7 +62,7 @@ class CompactionTest {
val reviewer = if (memoryStore != null) KeywordMdReviewer() else null val reviewer = if (memoryStore != null) KeywordMdReviewer() else null
return ChatAgent( return ChatAgent(
id = "test", id = "test",
stores = stores, storage = storage,
llm = fakeLlm, llm = fakeLlm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = LlmBackend.OPENAI, backend = LlmBackend.OPENAI,
@@ -86,7 +86,7 @@ class CompactionTest {
repeat(10) { repeat(10) {
conv.send(listOf(ProtoContent.Text("turn $it: ${"x".repeat(200)}"))) conv.send(listOf(ProtoContent.Text("turn $it: ${"x".repeat(200)}")))
} }
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.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.storage.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")
@@ -98,7 +98,7 @@ class CompactionTest {
val agent = newAgent(contextWindow = 10, compactor = null) val agent = newAgent(contextWindow = 10, compactor = null)
val conv = agent.createConversation(temp = false) as ChatConversation val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(ProtoContent.Text("first"))) conv.send(listOf(ProtoContent.Text("first")))
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.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.storage.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")
@@ -117,7 +117,7 @@ class CompactionTest {
// Compactor должен был быть вызван хотя бы раз. // Compactor должен был быть вызван хотя бы раз.
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 = storage.workingMemoryStore.list(conv.id)
val summaries = wm.filter { it.entry is pw.binom.agentik.storage.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.
@@ -157,7 +157,7 @@ class CompactionTest {
conv.send(listOf(ProtoContent.Text("second turn"))) conv.send(listOf(ProtoContent.Text("second turn")))
conv.send(listOf(ProtoContent.Text("third turn — long content ${"y".repeat(150)}"))) conv.send(listOf(ProtoContent.Text("third turn — long content ${"y".repeat(150)}")))
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
// Должны быть: 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 {
@@ -47,13 +47,13 @@ import kotlin.time.Instant
*/ */
class MemoryWiringTest { class MemoryWiringTest {
private lateinit var stores: SqliteStores private lateinit var storage: pw.binom.agentik.storage.StorageBundle
private lateinit var fakeLlm: FakeLiteLlm private lateinit var fakeLlm: FakeLiteLlm
private lateinit var root: Path private lateinit var root: Path
@BeforeTest @BeforeTest
fun setup() { fun setup() {
stores = SqliteStores.inMemory() storage = SqliteStores.inMemory().asBundle()
fakeLlm = FakeLiteLlm() fakeLlm = FakeLiteLlm()
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}") root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
SystemFileSystem.createDirectories(root, mustCreate = true) SystemFileSystem.createDirectories(root, mustCreate = true)
@@ -61,7 +61,7 @@ class MemoryWiringTest {
@AfterTest @AfterTest
fun tearDown() { fun tearDown() {
stores.close() storage.close()
runCatching { SystemFileSystem.delete(root, mustExist = false) } runCatching { SystemFileSystem.delete(root, mustExist = false) }
} }
@@ -71,7 +71,7 @@ class MemoryWiringTest {
reviewer: MemoryReviewer, reviewer: MemoryReviewer,
): ChatAgent = ChatAgent( ): ChatAgent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = fakeLlm, llm = fakeLlm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = LlmBackend.OPENAI, backend = LlmBackend.OPENAI,
@@ -88,7 +88,7 @@ class MemoryWiringTest {
val system = openMdMemorySystem(root) val system = openMdMemorySystem(root)
val agent = newAgent(system.store, system.prefetcher, system.reviewer) val agent = newAgent(system.store, system.prefetcher, system.reviewer)
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System } val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)), assertTrue(text.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
@@ -102,7 +102,7 @@ class MemoryWiringTest {
val soulBody = "I am a helpful test persona. I always answer in one short line." val soulBody = "I am a helpful test persona. I always answer in one short line."
val agent = ChatAgent( val agent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = fakeLlm, llm = fakeLlm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = LlmBackend.OPENAI, backend = LlmBackend.OPENAI,
@@ -112,7 +112,7 @@ class MemoryWiringTest {
soulBody = soulBody, soulBody = soulBody,
) )
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System } val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.startsWith(soulBody), assertTrue(text.startsWith(soulBody),
@@ -126,7 +126,7 @@ class MemoryWiringTest {
fun `soul body not added when null`() = runBlocking { fun `soul body not added when null`() = runBlocking {
val agent = ChatAgent( val agent = ChatAgent(
id = "agentik", id = "agentik",
stores = stores, storage = storage,
llm = fakeLlm, llm = fakeLlm,
llmConfig = LlmConfig( llmConfig = LlmConfig(
backend = LlmBackend.OPENAI, backend = LlmBackend.OPENAI,
@@ -135,7 +135,7 @@ class MemoryWiringTest {
), ),
) )
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
val wm = stores.workingMemory.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System } val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.startsWith("be brief"), assertTrue(text.startsWith("be brief"),
@@ -6,6 +6,7 @@ import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import pw.binom.agentik.storage.ConversationStore import pw.binom.agentik.storage.ConversationStore
import pw.binom.agentik.storage.MessageStore import pw.binom.agentik.storage.MessageStore
import pw.binom.agentik.storage.ReflectionStore import pw.binom.agentik.storage.ReflectionStore
import pw.binom.agentik.storage.StorageBundle
import pw.binom.agentik.storage.sqlite.SqliteReflectionStore import pw.binom.agentik.storage.sqlite.SqliteReflectionStore
import pw.binom.agentik.storage.WorkingMemoryStore import pw.binom.agentik.storage.WorkingMemoryStore
@@ -21,6 +22,18 @@ class SqliteStores private constructor(
val reflections: ReflectionStore, val reflections: ReflectionStore,
) : AutoCloseable { ) : AutoCloseable {
/**
* Удобный агрегатор — превращает SqliteStores в [StorageBundle] для передачи
* в агенты, которые работают через общий контракт хранения (ChatAgent после
* commit 6 в :standalone принимает [StorageBundle], не [SqliteStores]).
*/
fun asBundle(): StorageBundle = StorageBundle(
conversationStore = conversations,
messageStore = messages,
workingMemoryStore = workingMemory,
reflectionStore = reflections,
)
override fun close() { override fun close() {
conversations.close() conversations.close()
messages.close() messages.close()