From 9fcb2da75d0e7bc9119bfeffb5e2f2d841650061 Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 16 Sep 2026 02:39:19 +0300 Subject: [PATCH] Remove system prompt persistence from working_memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System prompt is now built fresh at conversation create/load time (in `buildSystemPrompt` capturing current SOUL/skills/toolsets/reflections) and passed into LiteConversationConfig.systemInstruction. It is NOT written to working_memory anymore. Why: ChatAgent was freezing the system prompt into a WorkingMemoryEntry.System row at createConversation, then reading it back on every getOrCreateLiteConversation. This meant changing SOUL, activating toolsets, adding skills or new reflections between agent restarts did not propagate to existing conversations without re-running createConversation. Fix: - ChatAgent.createConversation: dropped the workingMemoryStore.append(System(...)) - ChatConversation.getOrCreateLiteConversation: replaces the WM-based lookup with the in-memory systemPrompt field directly - ChatConversation.compactPreTurn: same simplification — compaction operates only on User/Assistant rows (plus future Summary rows); system prompt is excluded Migration: none. Old DBs may contain dead System rows from prior versions — they are simply ignored by the new lookup, and compaction never reads them. Tests: 340/340 green. Updated 7 tests across ChatAgentTest + MemoryWiringTest that asserted the old System-in-working-memory contract; they now verify the system prompt via LiteConversationConfig.systemInstruction (what LLM actually sees). E2E verified: 0 system rows in working_memory across all conversations, multi-turn history reconstructs correctly after agent restart with the updated in-memory system prompt. --- .../agentik/standalone/agent/ChatAgent.kt | 5 -- .../standalone/agent/ChatConversation.kt | 23 ++++---- .../agentik/standalone/agent/ChatAgentTest.kt | 54 +++++++++++-------- .../standalone/agent/MemoryWiringTest.kt | 31 ++++++----- 4 files changed, 61 insertions(+), 52 deletions(-) diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index 1f5f51d..0dfc3a6 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -212,11 +212,6 @@ class ChatAgent( if (!temp) { runBlocking { storage.conversationStore.upsert(rec) - storage.workingMemoryStore.append( - conversationId = id, - entry = WorkingMemoryEntry.System(text = systemPrompt), - now = now, - ) } } val conv = ChatConversation( diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt index 816c2cb..3985cac 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt @@ -473,12 +473,12 @@ class ChatConversation( private suspend fun compactPreTurn(force: Boolean): Boolean { val window = contextWindow ?: return false val compactor = contextCompactor ?: return false + // System prompt живёт только in-memory в ChatConversation.systemPrompt — + // он не участвует ни в working_memory, ни в compaction. val wm = workingMemory.list(id) if (wm.isEmpty()) return false - val systemText = wm.firstOrNull { it.entry is WorkingMemoryEntry.System } - ?.let { (it.entry as WorkingMemoryEntry.System).text } - ?: systemPrompt + val systemText = systemPrompt val history = wm.filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant } val toolsChars = tools.sumOf { it.tool.describe().length } @@ -854,13 +854,14 @@ class ChatConversation( private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation { liteConv?.let { return it } - val wm = if (record.isTemporal) emptyList() else workingMemory.list(id) - val resolvedSystemPrompt = if (record.isTemporal) systemPrompt else wm - .firstOrNull { it.entry is WorkingMemoryEntry.System } - ?.let { (it.entry as WorkingMemoryEntry.System).text } - ?: systemPrompt - - val pastTurns: List = if (record.isTemporal) emptyList() else wm + // System prompt передаётся в LiteConversationConfig.systemInstruction, не в + // initialMessages. Хранится ТОЛЬКО in-memory в ChatConversation.systemPrompt, + // при каждом создании LiteConversation берётся свежий (отражает текущий SOUL, + // активные toolsets, актуальные skills/reflections на момент старта ChatAgent). + // В working_memory System-entries не пишем — иначе старые диалоги видели бы + // замороженный на момент создания промпт, и SOUL/toolsets не обновлялись бы + // без рестарта агента. + val pastTurns: List = if (record.isTemporal) emptyList() else workingMemory.list(id) .filter { row -> val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId @@ -880,7 +881,7 @@ class ChatConversation( } val config = LiteConversationConfig( - systemInstruction = resolvedSystemPrompt.takeIf { it.isNotBlank() }, + systemInstruction = systemPrompt.takeIf { it.isNotBlank() }, initialMessages = pastTurns, tools = tools.map { it.tool }, ) diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index cd31502..e3e7fd2 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -71,32 +71,41 @@ class ChatAgentTest { ) @Test - fun `createConversation seeds system prompt into working memory`() = runTest { + fun `createConversation does NOT seed system prompt into working memory`() = runTest { + // System prompt живёт ТОЛЬКО in-memory в ChatConversation.systemPrompt + // и едет в LLM через LiteConversationConfig.systemInstruction. В + // working_memory ничего не пишется — старт system prompt чистый, + // 0 entries. val agent = newAgent() val conv = agent.createConversation(temp = false) as ChatConversation 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 - assertEquals("be brief", sysEntry.text) + assertEquals(0, wm.size) + // System prompt виден через LiteConversationConfig, который LLM получит + // при первом send (см. `send passes system prompt and past history to LLM on first send`). } @Test - fun `skills are appended to the system prompt in working memory`() = runTest { + fun `skills are appended to the system prompt passed to LLM`() = runTest { + // Skills добавляются в system prompt на лету при сборке ChatAgent. + // Проверяем это через то, что увидит LLM — systemInstruction в + // LiteConversationConfig (а не через working_memory, куда теперь + // ничего про system prompt не пишется). val skills = SkillCatalog( listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), ) val agent = newAgent(skills = skills) - val conv = agent.createConversation(temp = false) as ChatConversation + val conv = agent.createConversation(temp = false) + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) - 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) - assertTrue("lint" in system.text) - assertTrue("lint things" in system.text) - assertFalse("SECRET BODY" in system.text, "system prompt must not leak the skill body") + val system = fakeLlm.lastConfig!!.systemInstruction + assertNotNull(system) + assertTrue("be brief" in system!!, "base prompt missing: $system") + assertTrue("## Навыки" in system, "skills section missing: $system") + assertTrue("lint" in system, "skill name missing: $system") + assertTrue("lint things" in system, "skill description missing: $system") + assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body") } @Test @@ -199,12 +208,13 @@ class ChatAgentTest { fakeLlm.reply = "second reply" conv2.send(listOf(Content.Text("second user"))) - // Первая беседа должна иметь только свою систему + 1 user + 1 assistant + // В working_memory теперь НЕТ System-entries — только user + assistant. + // Системный промт живёт в ChatConversation.systemPrompt и едет в LLM + // через LiteConversationConfig.systemInstruction. val wm1 = storage.workingMemoryStore.list(conv1.id) - assertEquals(3, wm1.size) - // Вторая беседа — только своё + assertEquals(2, wm1.size) val wm2 = storage.workingMemoryStore.list(conv2.id) - assertEquals(3, wm2.size) + assertEquals(2, wm2.size) } @Test @@ -236,14 +246,14 @@ class ChatAgentTest { fakeLlm.reply = "first reply" conv.send(listOf(Content.Text("first user"))) - // первый turn: WM = [system, user, assistant] - assertEquals(3, storage.workingMemoryStore.list(conv.id).size) + // первый turn: WM = [user, assistant] (system prompt не пишется в WM) + assertEquals(2, storage.workingMemoryStore.list(conv.id).size) fakeLlm.reply = "second reply" conv.send(listOf(Content.Text("second user"))) - // второй turn: WM должен вырасти до [system, user, assistant, user, assistant] + // второй turn: WM должен вырасти до [user, assistant, user, assistant] val wm = storage.workingMemoryStore.list(conv.id) - assertEquals(5, wm.size) + assertEquals(4, wm.size) // Длинно-живущий LiteConversation: один на ChatConversation, история // накапливается через sendStreamContents, без пересоздания. assertEquals(1, fakeLlm.conversations.size) diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt index e19988c..5c30dd1 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt @@ -29,7 +29,6 @@ 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.storage.WorkingMemoryEntry import pw.binom.agentik.storage.sqlite.SqliteStores import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -88,11 +87,13 @@ class MemoryWiringTest { val system = openMdMemorySystem(root) val agent = newAgent(system.store, system.prefetcher, system.reviewer) val conv = agent.createConversation(temp = false) - 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)), - "system prompt should contain MEMORY_GUIDANCE") + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + // System prompt не пишется в working_memory — читаем то, что увидит LLM + val text = fakeLlm.lastConfig?.systemInstruction + assertNotNull(text) + assertTrue(text!!.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)), + "system prompt should contain MEMORY_GUIDANCE; got first 200 chars: ${text.take(200)}") agent.close() system.close() } @@ -112,10 +113,11 @@ class MemoryWiringTest { soulBody = soulBody, ) val conv = agent.createConversation(temp = false) - 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), + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + val text = fakeLlm.lastConfig?.systemInstruction + assertNotNull(text) + assertTrue(text!!.startsWith(soulBody), "soul should be the very first section; got first 60 chars: ${text.take(60)}") assertTrue(text.contains("be brief"), "base prompt should still follow the soul; got: $text") @@ -135,10 +137,11 @@ class MemoryWiringTest { ), ) val conv = agent.createConversation(temp = false) - 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"), + fakeLlm.reply = "ok" + conv.send(listOf(Content.Text("hi"))) + val text = fakeLlm.lastConfig?.systemInstruction + assertNotNull(text) + assertTrue(text!!.startsWith("be brief"), "without soul, prompt should start with base; got first 60 chars: ${text.take(60)}") agent.close() }