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) {
runBlocking {
storage.conversationStore.upsert(rec)
storage.workingMemoryStore.append(
conversationId = id,
entry = WorkingMemoryEntry.System(text = systemPrompt),
now = now,
)
}
}
val conv = ChatConversation(
@@ -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<LiteMessage> = 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<LiteMessage> = 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 },
)
@@ -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)
@@ -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()
}