feat(client): client-side caching for conversation list via agent.outbox events
Adds `agent.conversationStore` (read-only view on `conversation` table) to
the :proto Agent interface, plus `agent.renameConversation(id, title?)`
command. Client-side cache in :client is built from a snapshot
(`remote.listFlow(0)` → `local.upsert(...)`) + live updates via
`outbox.agentEvents()` (Created/Deleted/Renamed/Touched).
Changes:
- :journal-api — split `ConversationStore` (read-only: get/list) and
`MutableConversationStore` (CRUD: upsert/delete/rename/touch);
`ConversationStore` gained `listFlow` (cold-flow paging via `list`).
- :outbox-api — `AgentEvent.Touched(date, id, updatedAt)` event so
client cache stays fresh after `send()` (which bumps `updatedAt`).
- :proto.Agent — added `conversationStore: ConversationStore` property,
added `renameConversation(id, title?): Instant?` command, removed
`getConversations(offset, limit)` (now: `conversationStore.list(...)`).
- :server — `GET /conversations` now returns `List<ConversationRecord>`
(lightweight metadata, no handle/image-support flags); `PATCH
/conversations/{id}` uses `agent.renameConversation` and returns
the updated `ConversationRecord`.
- :journal-inmemory — expanded targets to jvm+macos+linux+mingw (matches
:client); moved `InMemoryMutableConversationStore` here from
:storage-inmemory so :client can use it without pulling ios targets.
- :storage-inmemory — depends on :journal-inmemory.
- :storage-ksqlite — pre-staged rename `KsqliteConversationStore` →
`KsqliteMutableConversationStore` to match the new interface split.
- :standalone — `ChatAgent` exposes `conversationStore` as a read-only
view of its `mutableConversationStore`; emits `AgentEvent.Touched`
after each `send()` (after `conversationStore.touch(id, ts)`).
- :client — new `HttpConversationStore` (read-only HTTP impl);
`AgentikAgent` wraps the agent with `wrapWithLocalConversationCache`
so the client sees an in-memory cache (snapshot + outbox events)
instead of direct HTTP. Cache scope + HttpClient + background job
all cancelled in `agent.close()`.
- :client/README — new «Кэш списка бесед» section with the
`listFlow → upsert` / `agentEvents → apply` pattern and a note that
`conversationStore` is read-only (writes only via Agent commands).
All 96 jvmTest tasks green.
This commit is contained in:
@@ -363,7 +363,7 @@ private fun runServer() {
|
||||
|
||||
val agent = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -18,7 +13,6 @@ import pw.binom.agentik.memory.MemorySystemGuidance
|
||||
import pw.binom.agentik.proto.Agent as ProtoAgent
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
@@ -29,7 +23,7 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.Ids
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.reflection.Reflection
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
import pw.binom.agentik.journal.MutableJournalStore
|
||||
@@ -65,7 +59,7 @@ import pw.binom.litert.LiteLlm
|
||||
*/
|
||||
class ChatAgent(
|
||||
override val id: String,
|
||||
private val conversationStore: ConversationStore,
|
||||
private val mutableConversationStore: MutableConversationStore,
|
||||
private val messageStore: MutableJournalStore,
|
||||
private val workingMemoryStore: ContextStore,
|
||||
private val reflectionStore: ReflectionStore,
|
||||
@@ -239,6 +233,16 @@ class ChatAgent(
|
||||
override val outbox: OutboxStore
|
||||
get() = eventStore
|
||||
|
||||
/**
|
||||
* Read-only view of [mutableConversationStore] для HTTP-фасада в `:server`
|
||||
* (`GET /conversations` → список [ConversationRecord] для UI).
|
||||
*
|
||||
* Сужение с [MutableConversationStore] на [ConversationStore] тривиальна
|
||||
* через interface-наследование (read-only projection).
|
||||
*/
|
||||
override val conversationStore: ConversationStore
|
||||
get() = mutableConversationStore
|
||||
|
||||
/** Защищает карту живых диалогов. */
|
||||
private val liveLock = Mutex()
|
||||
private val live: MutableMap<String, ChatConversation> = HashMap()
|
||||
@@ -262,12 +266,12 @@ class ChatAgent(
|
||||
// и не переживают рестарт агента (см. Memory #3709).
|
||||
if (!temp) {
|
||||
runBlocking {
|
||||
conversationStore.upsert(rec)
|
||||
mutableConversationStore.upsert(rec)
|
||||
}
|
||||
}
|
||||
val conv = ChatConversation(
|
||||
record = rec,
|
||||
conversationStore = conversationStore,
|
||||
conversationStore = mutableConversationStore,
|
||||
messageStore = messageStore,
|
||||
workingMemoryStore = workingMemoryStore,
|
||||
reflectionStore = reflectionStore,
|
||||
@@ -302,7 +306,7 @@ class ChatAgent(
|
||||
|
||||
override suspend fun getConversation(id: String): ProtoConversation? {
|
||||
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
|
||||
val rec = conversationStore.get(id) ?: return null
|
||||
val rec = mutableConversationStore.get(id) ?: return null
|
||||
return newConversation(rec).also {
|
||||
liveLock.withLock { live[id] = it }
|
||||
}
|
||||
@@ -311,7 +315,7 @@ class ChatAgent(
|
||||
override suspend fun deleteConversation(id: String): Boolean {
|
||||
val conv = liveLock.withLock { live.remove(id) }
|
||||
conv?.close()
|
||||
val ok = conversationStore.delete(id)
|
||||
val ok = mutableConversationStore.delete(id)
|
||||
if (ok) {
|
||||
val event = AgentEvent.Deleted(date = now(), id = id)
|
||||
eventStore.append(CommonEvent.Agent(date = now(), event = event))
|
||||
@@ -319,17 +323,16 @@ class ChatAgent(
|
||||
return ok
|
||||
}
|
||||
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
|
||||
conversationStore.list(offset = offset, limit = limit).map { rec ->
|
||||
liveLock.withLock { live[rec.id] }
|
||||
?: newConversation(rec).also {
|
||||
liveLock.withLock { live[rec.id] = it }
|
||||
}
|
||||
}
|
||||
override suspend fun renameConversation(id: String, title: String?): Instant? {
|
||||
val newUpdatedAt = mutableConversationStore.rename(id, title) ?: return null
|
||||
val event = AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title)
|
||||
eventStore.append(CommonEvent.Agent(date = newUpdatedAt, event = event))
|
||||
return newUpdatedAt
|
||||
}
|
||||
|
||||
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
|
||||
record = rec,
|
||||
conversationStore = conversationStore,
|
||||
conversationStore = mutableConversationStore,
|
||||
messageStore = messageStore,
|
||||
workingMemoryStore = workingMemoryStore,
|
||||
reflectionStore = reflectionStore,
|
||||
|
||||
+14
-3
@@ -7,7 +7,6 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -31,7 +30,7 @@ import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
||||
import pw.binom.agentik.skills.SkillStore
|
||||
import pw.binom.agentik.journal.Content
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.MessageContext
|
||||
import pw.binom.agentik.journal.MessageOrigin
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
@@ -54,7 +53,7 @@ import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
class ConversationLoop(
|
||||
record: ConversationRecord,
|
||||
private val conversationStore: ConversationStore,
|
||||
private val conversationStore: MutableConversationStore,
|
||||
private val messageStore: MutableJournalStore,
|
||||
private val workingMemoryStore: ContextStore,
|
||||
private val reflectionStore: ReflectionStore?,
|
||||
@@ -440,6 +439,18 @@ class ConversationLoop(
|
||||
|
||||
state.record = state.record.copy(updatedAt = assistantAt)
|
||||
conversationStore.touch(id, assistantAt)
|
||||
if (!state.isTemporal) {
|
||||
eventStore.append(
|
||||
pw.binom.agentik.outbox.CommonEvent.Agent(
|
||||
date = assistantAt,
|
||||
event = pw.binom.agentik.outbox.AgentEvent.Touched(
|
||||
date = assistantAt,
|
||||
id = id,
|
||||
updatedAt = assistantAt,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus
|
||||
// (compaction/lifecycle/tool-failure events). Никаких interval-based
|
||||
|
||||
+24
-7
@@ -62,7 +62,7 @@ class ChatAgentTest {
|
||||
skills: SkillCatalog = SkillCatalog.EMPTY,
|
||||
): ChatAgent = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
@@ -146,15 +146,32 @@ class ChatAgentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConversations returns all stored persistent conversations`() = runTest {
|
||||
fun `conversationStore list returns all stored persistent conversations`() = runTest {
|
||||
val agent = newAgent()
|
||||
agent.createConversation(temp = false)
|
||||
agent.createConversation(temp = true)
|
||||
val list = agent.getConversations(0, 10)
|
||||
val list = agent.conversationStore.list(0, 10)
|
||||
// temp-беседы не персистятся, в списке только persistent
|
||||
assertEquals(1, list.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renameConversation updates title and emits event`() = runTest {
|
||||
val agent = newAgent()
|
||||
val conv = agent.createConversation(temp = false)
|
||||
val newTitle = "Renamed!"
|
||||
val updatedAt = agent.renameConversation(conv.id, newTitle)
|
||||
assertNotNull(updatedAt)
|
||||
val rec = agent.conversationStore.get(conv.id)
|
||||
assertEquals(newTitle, rec?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renameConversation returns null for unknown id`() = runTest {
|
||||
val agent = newAgent()
|
||||
assertNull(agent.renameConversation("nope", "x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteConversation removes conversation and data`() = runTest {
|
||||
val agent = newAgent()
|
||||
@@ -458,7 +475,7 @@ class ChatAgentTest {
|
||||
sqliteStores = KsqliteStores.open(dbPath)
|
||||
val agent1 = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
@@ -478,7 +495,7 @@ class ChatAgentTest {
|
||||
sqliteStores = KsqliteStores.open(dbPath)
|
||||
val agent2 = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
@@ -500,7 +517,7 @@ class ChatAgentTest {
|
||||
sqliteStores = KsqliteStores.open(dbPath)
|
||||
val agent1 = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
@@ -518,7 +535,7 @@ class ChatAgentTest {
|
||||
sqliteStores = KsqliteStores.open(dbPath)
|
||||
val agent2 = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ class ChatAgentToolsetsTest {
|
||||
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
|
||||
val agent = ChatAgent(
|
||||
id = "test-agent",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ class CompactionTest {
|
||||
val reviewer = if (memoryStore != null) KeywordMdReviewer() else null
|
||||
return ChatAgent(
|
||||
id = "test",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
|
||||
+3
-3
@@ -76,7 +76,7 @@ class MemoryWiringTest {
|
||||
contextCompactor: ContextCompactor = EchoCompactor,
|
||||
): ChatAgent = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
@@ -115,7 +115,7 @@ class MemoryWiringTest {
|
||||
val soulBody = "I am a helpful test persona. I always answer in one short line."
|
||||
val agent = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
@@ -143,7 +143,7 @@ class MemoryWiringTest {
|
||||
fun `soul body not added when null`() = runBlocking {
|
||||
val agent = ChatAgent(
|
||||
id = "agentik",
|
||||
conversationStore = sqliteStores.conversations,
|
||||
mutableConversationStore = sqliteStores.conversations,
|
||||
messageStore = sqliteStores.messages,
|
||||
workingMemoryStore = sqliteStores.workingMemory,
|
||||
reflectionStore = sqliteStores.reflections,
|
||||
|
||||
Reference in New Issue
Block a user