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:
@@ -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(
|
||||||
|
|||||||
+12
-11
@@ -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)
|
||||||
|
|||||||
+17
-14
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user