Remove system prompt persistence from working_memory

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.
This commit is contained in:
2026-09-16 02:39:19 +03:00
parent 88af57182f
commit 9fcb2da75d
4 changed files with 61 additions and 52 deletions
@@ -212,11 +212,6 @@ class ChatAgent(
if (!temp) { if (!temp) {
runBlocking { runBlocking {
storage.conversationStore.upsert(rec) storage.conversationStore.upsert(rec)
storage.workingMemoryStore.append(
conversationId = id,
entry = WorkingMemoryEntry.System(text = systemPrompt),
now = now,
)
} }
} }
val conv = ChatConversation( val conv = ChatConversation(
@@ -473,12 +473,12 @@ class ChatConversation(
private suspend fun compactPreTurn(force: Boolean): Boolean { private suspend fun compactPreTurn(force: Boolean): Boolean {
val window = contextWindow ?: return false val window = contextWindow ?: return false
val compactor = contextCompactor ?: return false val compactor = contextCompactor ?: return false
// System prompt живёт только in-memory в ChatConversation.systemPrompt —
// он не участвует ни в working_memory, ни в compaction.
val wm = workingMemory.list(id) val wm = workingMemory.list(id)
if (wm.isEmpty()) return false if (wm.isEmpty()) return false
val systemText = wm.firstOrNull { it.entry is WorkingMemoryEntry.System } val systemText = systemPrompt
?.let { (it.entry as WorkingMemoryEntry.System).text }
?: systemPrompt
val history = wm.filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant } val history = wm.filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant }
val toolsChars = tools.sumOf { it.tool.describe().length } val toolsChars = tools.sumOf { it.tool.describe().length }
@@ -854,13 +854,14 @@ class ChatConversation(
private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation { private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation {
liteConv?.let { return it } liteConv?.let { return it }
val wm = if (record.isTemporal) emptyList() else workingMemory.list(id) // System prompt передаётся в LiteConversationConfig.systemInstruction, не в
val resolvedSystemPrompt = if (record.isTemporal) systemPrompt else wm // initialMessages. Хранится ТОЛЬКО in-memory в ChatConversation.systemPrompt,
.firstOrNull { it.entry is WorkingMemoryEntry.System } // при каждом создании LiteConversation берётся свежий (отражает текущий SOUL,
?.let { (it.entry as WorkingMemoryEntry.System).text } // активные toolsets, актуальные skills/reflections на момент старта ChatAgent).
?: systemPrompt // В working_memory System-entries не пишем — иначе старые диалоги видели бы
// замороженный на момент создания промпт, и SOUL/toolsets не обновлялись бы
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else wm // без рестарта агента.
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else workingMemory.list(id)
.filter { row -> .filter { row ->
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
@@ -880,7 +881,7 @@ class ChatConversation(
} }
val config = LiteConversationConfig( val config = LiteConversationConfig(
systemInstruction = resolvedSystemPrompt.takeIf { it.isNotBlank() }, systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
initialMessages = pastTurns, initialMessages = pastTurns,
tools = tools.map { it.tool }, tools = tools.map { it.tool },
) )
@@ -71,32 +71,41 @@ class ChatAgentTest {
) )
@Test @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 agent = newAgent()
val conv = agent.createConversation(temp = false) as ChatConversation val conv = agent.createConversation(temp = false) as ChatConversation
val wm = storage.workingMemoryStore.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
assertEquals(1, wm.size) assertEquals(0, wm.size)
val first = wm[0] // System prompt виден через LiteConversationConfig, который LLM получит
val sysEntry = first.entry as pw.binom.agentik.storage.WorkingMemoryEntry.System // при первом send (см. `send passes system prompt and past history to LLM on first send`).
assertEquals("be brief", sysEntry.text)
} }
@Test @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( val skills = SkillCatalog(
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")), listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
) )
val agent = newAgent(skills = skills) 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 val system = fakeLlm.lastConfig!!.systemInstruction
as pw.binom.agentik.storage.WorkingMemoryEntry.System assertNotNull(system)
assertTrue("be brief" in system.text) assertTrue("be brief" in system!!, "base prompt missing: $system")
assertTrue("## Навыки" in system.text) assertTrue("## Навыки" in system, "skills section missing: $system")
assertTrue("lint" in system.text) assertTrue("lint" in system, "skill name missing: $system")
assertTrue("lint things" in system.text) assertTrue("lint things" in system, "skill description missing: $system")
assertFalse("SECRET BODY" in system.text, "system prompt must not leak the skill body") assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body")
} }
@Test @Test
@@ -199,12 +208,13 @@ class ChatAgentTest {
fakeLlm.reply = "second reply" fakeLlm.reply = "second reply"
conv2.send(listOf(Content.Text("second user"))) 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) val wm1 = storage.workingMemoryStore.list(conv1.id)
assertEquals(3, wm1.size) assertEquals(2, wm1.size)
// Вторая беседа — только своё
val wm2 = storage.workingMemoryStore.list(conv2.id) val wm2 = storage.workingMemoryStore.list(conv2.id)
assertEquals(3, wm2.size) assertEquals(2, wm2.size)
} }
@Test @Test
@@ -236,14 +246,14 @@ 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 = [user, assistant] (system prompt не пишется в WM)
assertEquals(3, storage.workingMemoryStore.list(conv.id).size) assertEquals(2, 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 должен вырасти до [user, assistant, user, assistant]
val wm = storage.workingMemoryStore.list(conv.id) val wm = storage.workingMemoryStore.list(conv.id)
assertEquals(5, wm.size) assertEquals(4, wm.size)
// Длинно-живущий LiteConversation: один на ChatConversation, история // Длинно-живущий LiteConversation: один на ChatConversation, история
// накапливается через sendStreamContents, без пересоздания. // накапливается через sendStreamContents, без пересоздания.
assertEquals(1, fakeLlm.conversations.size) assertEquals(1, fakeLlm.conversations.size)
@@ -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.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.sqlite.SqliteStores import pw.binom.agentik.storage.sqlite.SqliteStores
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
@@ -88,11 +87,13 @@ 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 = storage.workingMemoryStore.list(conv.id) fakeLlm.reply = "ok"
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System } conv.send(listOf(Content.Text("hi")))
val text = (sysRow.entry as WorkingMemoryEntry.System).text // System prompt не пишется в working_memory — читаем то, что увидит LLM
assertTrue(text.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)), val text = fakeLlm.lastConfig?.systemInstruction
"system prompt should contain MEMORY_GUIDANCE") 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() agent.close()
system.close() system.close()
} }
@@ -112,10 +113,11 @@ class MemoryWiringTest {
soulBody = soulBody, soulBody = soulBody,
) )
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
val wm = storage.workingMemoryStore.list(conv.id) fakeLlm.reply = "ok"
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System } conv.send(listOf(Content.Text("hi")))
val text = (sysRow.entry as WorkingMemoryEntry.System).text val text = fakeLlm.lastConfig?.systemInstruction
assertTrue(text.startsWith(soulBody), assertNotNull(text)
assertTrue(text!!.startsWith(soulBody),
"soul should be the very first section; got first 60 chars: ${text.take(60)}") "soul should be the very first section; got first 60 chars: ${text.take(60)}")
assertTrue(text.contains("be brief"), assertTrue(text.contains("be brief"),
"base prompt should still follow the soul; got: $text") "base prompt should still follow the soul; got: $text")
@@ -135,10 +137,11 @@ class MemoryWiringTest {
), ),
) )
val conv = agent.createConversation(temp = false) val conv = agent.createConversation(temp = false)
val wm = storage.workingMemoryStore.list(conv.id) fakeLlm.reply = "ok"
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System } conv.send(listOf(Content.Text("hi")))
val text = (sysRow.entry as WorkingMemoryEntry.System).text val text = fakeLlm.lastConfig?.systemInstruction
assertTrue(text.startsWith("be brief"), assertNotNull(text)
assertTrue(text!!.startsWith("be brief"),
"without soul, prompt should start with base; got first 60 chars: ${text.take(60)}") "without soul, prompt should start with base; got first 60 chars: ${text.take(60)}")
agent.close() agent.close()
} }