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() }