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