Добавляет Cursor/OffsetSequencer в :outbox-api и интегрирует PersistentOffsetSequencer через :outbox-ksqlite.
Введение монотонного offset'а как персистентного состояния агента:
offset'ы переживают рестарт standalone-агента, клиент продолжает
синхронизацию инкрементально, без полной re-sync с нуля.
outbox-api:
- Cursor (offset: Long) — курсор в журнале событий агента.
- OffsetSequencer — интерфейс резервирования уникального offset.
- CursorStore — персистентное хранилище текущего offset'а.
- PersistentOffsetSequencer — декоратор над любым OutboxStore,
обновляет CursorStore на каждом append (atomic transaction).
- OutboxGapException — клиент запросил after < earliestCursor() →
сервер не может удовлетворить, клиент обязан делать full resync.
- DurableEvent переименован из Event.kt → DurableEvent.kt (Event.kt
был общим sealed-типом, теперь это термин из спеки).
- MutableOutboxStore и OutboxStore теперь читают offset через
CursorStore вместо in-memory counter'а.
outbox-inmemory:
- InMemoryOffsetSequencer — для тестов и dev-режима.
- InMemoryOutboxStore теперь принимает OffsetSequencer в конструкторе.
outbox-ksqlite (новый модуль):
- KsqliteCursorStore — таблица outbox_cursor (agent_id TEXT PK,
offset INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL).
- KsqliteCursorStoreTest — 4 теста (set/get, monotonic, concurrent).
proto + server:
- Snapshot.proto — server-state snapshot endpoint для клиентов,
которым нужна полная материализация (использование TBD).
- Routes.kt + SnapshotRouteTest — endpoint /agentik/snapshot (GET).
journal-ksqlite:
- KsqliteJournalStore.listFlow/append — без изменений по API,
нотации минимальные (codecs).
standalone:
- DurableLog (бывший ChatAgent-orchestration) — атомарный commit
события в OutboxStore + PersistentOffsetSequencer + materialization
(через Reducer) одной транзакцией.
- SqliteStores — добавляет KsqliteCursorStore в bundle, единая
shared-connection для всех ksqlite-сторов standalone-агента.
- ChatAgent / ConversationLoop / ConversationEvents / ReflectionScheduler /
ToolDispatcher — переход на новые абстракции.
- standalone/build.gradle.kts — implementation(project(':outbox-ksqlite'))
включено (раньше было закомментировано — модуль только создавался).
client:
- AgentikAgent / AgentClient / HttpEventStore / HttpJournalStore /
ReconnectingOutbox — используют Cursor через transport API.
- client/README.md — синхронизирован с новым поведением (468 строк
diff — это в основном оформление и примеры).
kotlinx-io: 0.8.0 → 0.9.1 в libs.versions.toml (см. sync-core tests).
SYNC-SYSTEM.md (в корне) — спецификация, на которую ссылается и
:sync-core (эта сессия), и эта Cursor-абстракция в outbox-api.
Тесты: standalone 132, journal-ksqlite 25, outbox-inmemory 20,
outbox-ksqlite 4, client 10, sync-core 74 — все зелёные на jvm;
sync-core linuxX64 74 тоже зелёный.
sync2/ (заброшенный stub с одним build.gradle.kts) удалён.
This commit is contained in:
@@ -74,6 +74,9 @@ kotlin {
|
||||
|
||||
// Bounded-tail live event stream + per-event TTL.
|
||||
implementation(project(":outbox-inmemory"))
|
||||
// Персистентный счётчик событий (CursorStore) поверх ksqlite —
|
||||
// offset'ы переживают рестарт, клиент продолжает инкрементально.
|
||||
implementation(project(":outbox-ksqlite"))
|
||||
|
||||
// :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider.
|
||||
// ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.)
|
||||
|
||||
@@ -10,7 +10,7 @@ import pw.binom.a2a.model.Message
|
||||
import pw.binom.a2a.model.Role
|
||||
import pw.binom.a2a.model.TextPart
|
||||
import pw.binom.a2a.server.AgentHandler
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.content.Content
|
||||
@@ -31,7 +31,7 @@ private val log = KotlinLogging.logger {}
|
||||
* Ответ A2A = склеенные [OnlineEvent.AppendText] нашего хода. Подписку на онлайн-поток
|
||||
* ([pw.binom.agentik.outbox.OnlineOutbox]) открываем ДО [Conversation.send] (live-only,
|
||||
* без catchup — события начала хода иначе можно упустить), завершение хода ждём
|
||||
* по онлайн [OnlineEvent.End] и durable [Event.AssistantMessage] / [Event.Interrupted] / [Event.Error].
|
||||
* по онлайн [OnlineEvent.End] и durable [DurableEvent.AssistantMessage] / [DurableEvent.Interrupted] / [DurableEvent.Error].
|
||||
*
|
||||
* Ограничение v1: tool-события и картинки в A2A-ответ не транслируются;
|
||||
* при нескольких ходов в очереди за контекстом текст предыдущего хода
|
||||
@@ -47,7 +47,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
||||
.joinToString("\n") { it.text }
|
||||
val conv = resolveConversation(contextId)
|
||||
|
||||
val since = conv.updatedAt
|
||||
// Курсор старта: снапшот не нужен, достаточно текущей позиции лога.
|
||||
val since = agent.outbox.currentCursor()
|
||||
val reply = StringBuilder()
|
||||
val turnDone = CompletableDeferred<Unit>()
|
||||
// Онлайн-поток — дельты ответа (live-only, без catchup).
|
||||
@@ -66,8 +67,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
||||
val turnJob = async {
|
||||
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
||||
when (val e = ce.event) {
|
||||
is Event.AssistantMessage, is Event.Interrupted -> turnDone.complete(Unit)
|
||||
is Event.Error ->
|
||||
is DurableEvent.AssistantMessage, is DurableEvent.Interrupted -> turnDone.complete(Unit)
|
||||
is DurableEvent.Error ->
|
||||
turnDone.completeExceptionally(
|
||||
IllegalStateException("agent turn failed: ${e.message}")
|
||||
)
|
||||
|
||||
@@ -392,6 +392,9 @@ private fun runServer() {
|
||||
contextCompactor = contextCompactor,
|
||||
recentReflections = recentReflections,
|
||||
reflector = reflector,
|
||||
// Персистентный счётчик событий: offset/epoch переживают рестарт,
|
||||
// клиенты продолжают инкрементально, а не делают полный resync.
|
||||
outboxSequencer = sqliteStores.outboxSequencer,
|
||||
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
|
||||
|
||||
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
|
||||
|
||||
@@ -5,9 +5,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -25,11 +23,16 @@ import pw.binom.agentik.memory.MemoryReviewer
|
||||
import pw.binom.agentik.memory.MemorySystemGuidance
|
||||
import pw.binom.agentik.proto.Agent as ProtoAgent
|
||||
import pw.binom.agentik.proto.AgentInfo
|
||||
import pw.binom.agentik.proto.ChatSnapshot
|
||||
import pw.binom.agentik.proto.ConversationsSnapshot
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.Event as OutboxEvent
|
||||
import pw.binom.agentik.outbox.DurableEvent as OutboxEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
import pw.binom.agentik.outbox.inmemory.InMemoryOffsetSequencer
|
||||
import pw.binom.agentik.outbox.OnlineOutbox
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
@@ -47,10 +50,7 @@ import pw.binom.agentik.reflection.Reflection
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
import pw.binom.agentik.journal.MutableJournalStore
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
import pw.binom.agentik.toolsets.DisableToolsetTool
|
||||
import pw.binom.agentik.toolsets.EnableToolsetTool
|
||||
import pw.binom.litert.LiteTool
|
||||
import pw.binom.agentik.toolsets.SystemPromptToolsetSection
|
||||
import pw.binom.agentik.toolsets.ToolsetComponent
|
||||
import pw.binom.agentik.toolsets.ToolsetContribution
|
||||
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
|
||||
@@ -147,6 +147,14 @@ internal class ChatAgent(
|
||||
* секция в system prompt НЕ добавляется (полная невидимость per A1-α).
|
||||
*/
|
||||
private val toolsets: List<ToolsetContribution> = emptyList(),
|
||||
/**
|
||||
* Счётчик событий агента. По умолчанию in-memory: offset'ы начинаются с 0
|
||||
* при каждом старте процесса. Production передаёт
|
||||
* [pw.binom.agentik.outbox.PersistentOffsetSequencer] поверх ksqlite
|
||||
* (`SqliteStores.outboxSequencer`), чтобы `offset`/`epoch` переживали
|
||||
* рестарт и клиент продолжал инкрементально (см. [Cursor]).
|
||||
*/
|
||||
private val outboxSequencer: OffsetSequencer = InMemoryOffsetSequencer(),
|
||||
) : MutableAgent, AutoCloseable {
|
||||
|
||||
/**
|
||||
@@ -213,8 +221,16 @@ private val testTools: MutableList<LiteTool> = mutableListOf()
|
||||
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
|
||||
maxMessages = null,
|
||||
ttl = null,
|
||||
sequencer = outboxSequencer,
|
||||
)
|
||||
|
||||
/**
|
||||
* Сериализатор durable-записей: reserve offset → state → event (см. [DurableLog]).
|
||||
* Делит [eventStore] со всеми беседами агента, поэтому offset'ы сквозные
|
||||
* по агенту (и по всем беседам сразу).
|
||||
*/
|
||||
private val durableLog: DurableLog = DurableLog(eventStore)
|
||||
|
||||
/**
|
||||
* Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры).
|
||||
* Онлайн-события никогда не сохраняются и не реплеятся — см. [OnlineOutbox].
|
||||
@@ -473,7 +489,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
|
||||
messageStore = messageStore,
|
||||
workingMemoryStore = workingMemoryStore,
|
||||
reflectionStore = reflectionStore,
|
||||
eventStore = eventStore,
|
||||
durableLog = durableLog,
|
||||
onlineEventStore = onlineEventStore,
|
||||
llm = llm,
|
||||
systemPrompt = systemPrompt,
|
||||
@@ -497,12 +513,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
|
||||
// фоновые задачи.
|
||||
attachConversation(conv.asHandle())
|
||||
runBlocking {
|
||||
eventStore.append(
|
||||
CommonEvent.Agent(
|
||||
date = now(),
|
||||
event = AgentEvent.Created(date = now(), conversationId = conv.id),
|
||||
)
|
||||
)
|
||||
durableLog.appendAgent(AgentEvent.Created(date = now(), conversationId = conv.id))
|
||||
}
|
||||
return conv
|
||||
}
|
||||
@@ -527,26 +538,60 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
|
||||
// отдельный store должен знать только про свою таблицу.
|
||||
messageStore.clear(id)
|
||||
workingMemoryStore.clear(id)
|
||||
val event = AgentEvent.Deleted(date = now(), id = id)
|
||||
eventStore.append(CommonEvent.Agent(date = now(), event = event))
|
||||
durableLog.appendAgent(AgentEvent.Deleted(date = now(), id = id))
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
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))
|
||||
durableLog.appendAgent(AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title))
|
||||
return newUpdatedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-first снапшот списка диалогов (см. [ProtoAgent.conversationsSnapshot]).
|
||||
* Курсор читается до состояния: изменения во время чтения получат offset
|
||||
* `> cursor` и приедут потоком.
|
||||
*/
|
||||
override suspend fun conversationsSnapshot(): ConversationsSnapshot {
|
||||
val cursor = durableLog.currentCursor()
|
||||
val out = ArrayList<ConversationRecord>()
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val page = mutableConversationStore.list(offset, ConversationStore.PAGE_SIZE)
|
||||
out += page
|
||||
if (page.size < ConversationStore.PAGE_SIZE) break
|
||||
offset += page.size
|
||||
}
|
||||
return ConversationsSnapshot(conversations = out, cursor = cursor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-first снапшот сообщений диалога с отсечкой `seq <= cursor.offset`
|
||||
* (keyset-пагинация, см. [ProtoAgent.chatSnapshot]).
|
||||
*/
|
||||
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot {
|
||||
val cursor = durableLog.currentCursor()
|
||||
val out = ArrayList<MessageRecord>()
|
||||
var afterSeq = -1L
|
||||
while (true) {
|
||||
val page = messageStore.list(conversationId, afterSeq, cursor.offset, JournalStore.PAGE_SIZE)
|
||||
if (page.isEmpty()) break
|
||||
out += page
|
||||
afterSeq = page.last().seq
|
||||
if (page.size < JournalStore.PAGE_SIZE) break
|
||||
}
|
||||
return ChatSnapshot(conversationId = conversationId, messages = out, cursor = cursor)
|
||||
}
|
||||
|
||||
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
|
||||
record = rec,
|
||||
conversationStore = mutableConversationStore,
|
||||
messageStore = messageStore,
|
||||
workingMemoryStore = workingMemoryStore,
|
||||
reflectionStore = reflectionStore,
|
||||
eventStore = eventStore,
|
||||
durableLog = durableLog,
|
||||
onlineEventStore = onlineEventStore,
|
||||
llm = llm,
|
||||
systemPrompt = systemPrompt,
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ internal class CompactionCoordinator(
|
||||
}
|
||||
if (toCompact.isEmpty()) return false
|
||||
|
||||
events.tryEmit(pw.binom.agentik.outbox.Event.CompactionTriggered(date = now(), conversationId = state.id, turnsCompacted = toCompact.size))
|
||||
events.tryEmit(pw.binom.agentik.outbox.DurableEvent.CompactionTriggered(date = now(), conversationId = state.id, turnsCompacted = toCompact.size))
|
||||
|
||||
val turns = toCompact.mapNotNull { row ->
|
||||
when (val e = row.entry) {
|
||||
|
||||
+30
-19
@@ -1,44 +1,55 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
|
||||
/**
|
||||
* Фасад эмиссии и чтения событий одного диалога. Разводит два канала:
|
||||
* - durable ([Event]) → [globalEventStore] (`:outbox`), с catchup по `after`;
|
||||
* - durable ([DurableEvent]) → [DurableLog] (`:outbox`), с catchup по [Cursor];
|
||||
* - online ([OnlineEvent]) → [onlineStore], live-only (без catchup).
|
||||
*
|
||||
* Все durable-эмиссии идут через [DurableLog], чтобы state-row и парное
|
||||
* событие получали один offset (см. KDoc [DurableLog]).
|
||||
*/
|
||||
internal class ConversationEvents(
|
||||
private val globalEventStore: MutableOutboxStore,
|
||||
private val durableLog: DurableLog,
|
||||
private val onlineStore: MutableOnlineOutbox,
|
||||
private val conversationId: String,
|
||||
) {
|
||||
fun tryEmit(event: Event): Boolean {
|
||||
runBlocking {
|
||||
globalEventStore.append(
|
||||
CommonEvent.Conversation(
|
||||
date = event.date,
|
||||
conversationId = conversationId,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Атомарная durable-запись: сначала [writeState] (journal/...) с
|
||||
* забронированным `seq`, затем парное событие [event] с тем же offset.
|
||||
*/
|
||||
suspend fun <T> commit(writeState: suspend (seq: Long) -> T, event: (seq: Long) -> DurableEvent): T =
|
||||
durableLog.commit(conversationId = conversationId, writeState = writeState, event = event)
|
||||
|
||||
/** Durable-событие без парной записи состояния. */
|
||||
suspend fun emit(event: DurableEvent) {
|
||||
durableLog.appendConversation(conversationId = conversationId, event = event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронный мост для мест без suspend-контекста (`close()`,
|
||||
* [CompactionCoordinator]). Блокирует вызывающий поток до записи —
|
||||
* используется только на редких путях.
|
||||
*/
|
||||
fun tryEmit(event: DurableEvent): Boolean {
|
||||
runBlocking { emit(event) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun events(after: kotlin.time.Instant?): Flow<Event> =
|
||||
globalEventStore.conversationEvents(after = after, conversationId = conversationId)
|
||||
fun events(after: Cursor?): Flow<DurableEvent> =
|
||||
durableLog.outbox.conversationEvents(after = after, conversationId = conversationId)
|
||||
.map { it.event }
|
||||
|
||||
/** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */
|
||||
fun tryEmitOnline(event: OnlineEvent): Boolean =
|
||||
onlineStore.tryAppendOnline(conversationId, event)
|
||||
onlineStore.tryAppendOnline(event)
|
||||
|
||||
/** Live-поток онлайн-событий диалога (без catchup — см. KDoc [pw.binom.agentik.outbox.OnlineOutbox]). */
|
||||
fun onlineEvents(): Flow<OnlineEvent> = onlineStore.onlineEvents(conversationId)
|
||||
|
||||
+76
-64
@@ -11,17 +11,13 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import mu.KotlinLogging
|
||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||
import pw.binom.agentik.memory.MemoryReviewer
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.content.Content as ProtoContent
|
||||
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||
import pw.binom.agentik.outbox.DurableEvent as ProtoEvent
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import pw.binom.agentik.outbox.MutableOnlineOutbox
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
@@ -54,7 +50,7 @@ class ConversationLoop(
|
||||
private val messageStore: MutableJournalStore,
|
||||
private val workingMemoryStore: ContextStore,
|
||||
private val reflectionStore: ReflectionStore?,
|
||||
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
|
||||
private val durableLog: DurableLog,
|
||||
/**
|
||||
* Live-канал онлайн-событий (дельты ответа). Не сохраняется; подписка
|
||||
* возможна только «онлайн». Durable-события по-прежнему в [eventStore].
|
||||
@@ -108,7 +104,7 @@ class ConversationLoop(
|
||||
)
|
||||
|
||||
private val events = ConversationEvents(
|
||||
globalEventStore = eventStore,
|
||||
durableLog = durableLog,
|
||||
onlineStore = onlineEventStore,
|
||||
conversationId = state.id,
|
||||
)
|
||||
@@ -148,7 +144,7 @@ class ConversationLoop(
|
||||
reflector = reflector,
|
||||
reflectionStore = reflectionStore,
|
||||
),
|
||||
eventStore = eventStore,
|
||||
eventStore = durableLog.events,
|
||||
conversationIdProvider = { id },
|
||||
).also { it.start(agentScope) }
|
||||
|
||||
@@ -183,7 +179,7 @@ class ConversationLoop(
|
||||
// увидел «агент работает» ещё до turnLock.withLock { launch } и до
|
||||
// первого токена от LLM. Working/End — онлайн-маркеры (live-only),
|
||||
// терминатор durable-части — AssistantMessage/Interrupted/Error.
|
||||
emitOnline(OnlineEvent.Working(date = turnStarted))
|
||||
emitOnline(OnlineEvent.Working(date = turnStarted, conversationId = id))
|
||||
val userMessageId = newId("msg")
|
||||
val storageContext = context?.toStorage()
|
||||
|
||||
@@ -196,25 +192,29 @@ class ConversationLoop(
|
||||
)
|
||||
|
||||
if (!state.isTemporal) {
|
||||
messageStore.append(userRecord)
|
||||
workingMemoryStore.append(
|
||||
conversationId = id,
|
||||
entry = WorkingMemoryEntry.User(
|
||||
sourceMessageId = userMessageId,
|
||||
content = userRecord.content,
|
||||
context = storageContext,
|
||||
),
|
||||
now = turnStarted,
|
||||
)
|
||||
// Durable-событие user-сообщения: позволяет восстановить историю
|
||||
// по курсору outbox без отдельного запроса в journal.
|
||||
emitEvent(
|
||||
ProtoEvent.UserMessage(
|
||||
date = turnStarted,
|
||||
id = userMessageId,
|
||||
content = userRecord.content,
|
||||
context = storageContext,
|
||||
)
|
||||
// State-first: journal.append + workingMemory под одним offset'ом,
|
||||
// затем парное durable-событие (см. DurableLog).
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
messageStore.append(userRecord.copy(seq = seq))
|
||||
workingMemoryStore.append(
|
||||
conversationId = id,
|
||||
entry = WorkingMemoryEntry.User(
|
||||
sourceMessageId = userMessageId,
|
||||
content = userRecord.content,
|
||||
context = storageContext,
|
||||
),
|
||||
now = turnStarted,
|
||||
)
|
||||
},
|
||||
event = {
|
||||
ProtoEvent.UserMessage(
|
||||
date = turnStarted,
|
||||
id = userMessageId,
|
||||
content = userRecord.content,
|
||||
context = storageContext,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ class ConversationLoop(
|
||||
// ловит это и делает final reflection (last chance вытащить insights).
|
||||
// После cancel() подписка умерла бы.
|
||||
runCatching {
|
||||
events.tryEmit(pw.binom.agentik.outbox.Event.ConversationClosing(date = now(), conversationId = id))
|
||||
events.tryEmit(pw.binom.agentik.outbox.DurableEvent.ConversationClosing(date = now(), conversationId = id))
|
||||
}
|
||||
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
|
||||
runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
|
||||
@@ -277,8 +277,14 @@ class ConversationLoop(
|
||||
compactor.compactPreTurnIfNeeded()
|
||||
}
|
||||
|
||||
emitOnline(OnlineEvent.StartReasoning(date = turnStarted))
|
||||
emitOnline(OnlineEvent.StartResponse(date = now(), responseType = OnlineEvent.ResponseType.TEXT))
|
||||
emitOnline(OnlineEvent.StartReasoning(date = turnStarted, conversationId = id))
|
||||
emitOnline(
|
||||
OnlineEvent.StartResponse(
|
||||
date = now(),
|
||||
conversationId = id,
|
||||
responseType = OnlineEvent.ResponseType.TEXT,
|
||||
)
|
||||
)
|
||||
|
||||
val parts = userRecord.content.mapNotNull { c ->
|
||||
when (c) {
|
||||
@@ -345,7 +351,7 @@ class ConversationLoop(
|
||||
lc.sendStreamContents(pendingParts).collect { delta ->
|
||||
if (delta.text.isNotEmpty()) {
|
||||
reply.append(delta.text)
|
||||
emitOnline(OnlineEvent.AppendText(date = now(), body = delta.text))
|
||||
emitOnline(OnlineEvent.AppendText(date = now(), conversationId = id, body = delta.text))
|
||||
}
|
||||
if (delta.toolCalls.isNotEmpty()) {
|
||||
collectedCalls.addAll(delta.toolCalls)
|
||||
@@ -383,7 +389,7 @@ class ConversationLoop(
|
||||
}
|
||||
if (delta.text.isNotEmpty()) {
|
||||
reply.append(delta.text)
|
||||
emitOnline(OnlineEvent.AppendText(date = now(), body = delta.text))
|
||||
emitOnline(OnlineEvent.AppendText(date = now(), conversationId = id, body = delta.text))
|
||||
}
|
||||
if (delta.toolCalls.isNotEmpty()) {
|
||||
nextCalls.addAll(delta.toolCalls)
|
||||
@@ -400,7 +406,7 @@ class ConversationLoop(
|
||||
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
|
||||
if (followUp.text.isNotEmpty()) {
|
||||
reply.append(followUp.text)
|
||||
emitOnline(OnlineEvent.AppendText(date = now(), body = followUp.text))
|
||||
emitOnline(OnlineEvent.AppendText(date = now(), conversationId = id, body = followUp.text))
|
||||
}
|
||||
if (followUp.toolCalls.isNotEmpty()) {
|
||||
collectedPostTool.addAll(followUp.toolCalls)
|
||||
@@ -456,17 +462,19 @@ class ConversationLoop(
|
||||
createdAt = assistantAt,
|
||||
tokens = turnTokens,
|
||||
)
|
||||
messageStore.append(assistantRecord)
|
||||
|
||||
// Durable-событие готового ответа агента.
|
||||
emitEvent(
|
||||
ProtoEvent.AssistantMessage(
|
||||
date = assistantAt,
|
||||
id = assistantId,
|
||||
content = assistantContent,
|
||||
reasoning = null,
|
||||
tokens = turnTokens,
|
||||
)
|
||||
// State-first: journal.append под забронированным offset'ом,
|
||||
// затем парное durable-событие.
|
||||
events.commit(
|
||||
writeState = { seq -> messageStore.append(assistantRecord.copy(seq = seq)) },
|
||||
event = {
|
||||
ProtoEvent.AssistantMessage(
|
||||
date = assistantAt,
|
||||
id = assistantId,
|
||||
content = assistantContent,
|
||||
reasoning = null,
|
||||
tokens = turnTokens,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
workingMemoryStore.append(
|
||||
@@ -489,14 +497,11 @@ class ConversationLoop(
|
||||
state.record = state.record.copy(updatedAt = assistantAt)
|
||||
conversationStore.touch(id, assistantAt)
|
||||
if (!state.isTemporal) {
|
||||
eventStore.append(
|
||||
pw.binom.agentik.outbox.CommonEvent.Agent(
|
||||
durableLog.appendAgent(
|
||||
pw.binom.agentik.outbox.AgentEvent.Touched(
|
||||
date = assistantAt,
|
||||
event = pw.binom.agentik.outbox.AgentEvent.Touched(
|
||||
date = assistantAt,
|
||||
id = id,
|
||||
updatedAt = assistantAt,
|
||||
),
|
||||
id = id,
|
||||
updatedAt = assistantAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -510,14 +515,14 @@ class ConversationLoop(
|
||||
if (wasInterrupted || interrupted.get()) {
|
||||
emitEvent(ProtoEvent.Interrupted(date = now()))
|
||||
}
|
||||
emitOnline(OnlineEvent.End(date = now()))
|
||||
emitOnline(OnlineEvent.End(date = now(), conversationId = id))
|
||||
|
||||
interrupted.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitEvent(event: ProtoEvent) {
|
||||
events.tryEmit(event)
|
||||
private suspend fun emitEvent(event: ProtoEvent) {
|
||||
events.emit(event)
|
||||
}
|
||||
|
||||
private fun emitOnline(event: OnlineEvent) {
|
||||
@@ -527,17 +532,24 @@ class ConversationLoop(
|
||||
private suspend fun failTurn(message: String, code: String? = null) {
|
||||
val ts = now()
|
||||
if (!state.isTemporal) {
|
||||
messageStore.append(
|
||||
MessageRecord.Error(
|
||||
id = newId("err"),
|
||||
conversationId = id,
|
||||
message = message,
|
||||
code = code,
|
||||
createdAt = ts,
|
||||
),
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
messageStore.append(
|
||||
MessageRecord.Error(
|
||||
id = newId("err"),
|
||||
conversationId = id,
|
||||
message = message,
|
||||
code = code,
|
||||
createdAt = ts,
|
||||
seq = seq,
|
||||
),
|
||||
)
|
||||
},
|
||||
event = { ProtoEvent.Error(date = ts, message = message, code = code) },
|
||||
)
|
||||
} else {
|
||||
emitEvent(ProtoEvent.Error(date = ts, message = message, code = code))
|
||||
}
|
||||
emitEvent(ProtoEvent.Error(date = ts, message = message, code = code))
|
||||
}
|
||||
|
||||
private fun now(): Instant =
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Cursor
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
|
||||
/**
|
||||
* Сериализатор durable-записей агента: единственное место, где state-row и
|
||||
* парное ему outbox-событие получают **один и тот же** монотонный offset.
|
||||
*
|
||||
* ## Зачем нужен commit-lock
|
||||
*
|
||||
* Протокол снапшотов держится на трёх инвариантах (см. README `:outbox-api`):
|
||||
* 1. **Producer**: сначала мутация состояния (journal/conversation), потом
|
||||
* `append` события — оба с одинаковым `seq`/`offset`;
|
||||
* 2. **Reader**: сначала `currentCursor()` (= последний **заапенденный**
|
||||
* offset), потом чтение состояния с отсечкой `seq <= C`; всё, что `> C`,
|
||||
* приедет потоком;
|
||||
* 3. **Идемпотентность** применения (upsert/delete по id).
|
||||
*
|
||||
* Инвариант (1) ломается, если два продюсера параллельно забронируют
|
||||
* offset'ы и запишут их в разном порядке (A резервирует 10, B — 11, B
|
||||
* аппендит первым → outbox отвергнет 10 как немонотонный, либо состояние
|
||||
* 10 «застрянет» ниже `currentCursor` и потеряется для подписчика).
|
||||
* [commit]/[appendConversation]/[appendAgent] держат [Mutex] на всё время
|
||||
* «reserve → запись состояния → append», поэтому durable-поток строго
|
||||
* линеен, а `currentCursor` всегда указывает на корректную точку отсечки.
|
||||
*
|
||||
* Живёт в `:standalone` (а не в `:outbox-api`), потому что знает про
|
||||
* journal/conversation-сторы; outbox остаётся тупым хранилищем событий.
|
||||
*/
|
||||
class DurableLog(
|
||||
val outbox: MutableOutboxStore,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Read-only представление — для потребителей (ReflectionScheduler и т.п.). */
|
||||
val events: OutboxStore get() = outbox
|
||||
|
||||
/** Точка отсечки для нового снапшота (см. протокол). */
|
||||
suspend fun currentCursor(): Cursor = outbox.currentCursor()
|
||||
|
||||
/**
|
||||
* Атомарно: забронировать offset → записать состояние с этим `seq` →
|
||||
* аппендить парное событие. Возвращает результат [writeState].
|
||||
*/
|
||||
suspend fun <T> commit(
|
||||
conversationId: String,
|
||||
writeState: suspend (seq: Long) -> T,
|
||||
event: (seq: Long) -> DurableEvent,
|
||||
): T = mutex.withLock {
|
||||
val seq = outbox.reserveOffset()
|
||||
val result = writeState(seq)
|
||||
val e = event(seq)
|
||||
outbox.append(CommonEvent.Conversation(date = e.date, offset = seq, conversationId = conversationId, event = e))
|
||||
result
|
||||
}
|
||||
|
||||
/** Durable-событие диалога без парной записи состояния (Closing, CompactionTriggered, ...). */
|
||||
suspend fun appendConversation(conversationId: String, event: DurableEvent): Long = mutex.withLock {
|
||||
val seq = outbox.reserveOffset()
|
||||
outbox.append(CommonEvent.Conversation(date = event.date, offset = seq, conversationId = conversationId, event = event))
|
||||
seq
|
||||
}
|
||||
|
||||
/** Агент-level событие (Created/Deleted/Renamed/Touched) — состояние мутируется вызывающим до вызова. */
|
||||
suspend fun appendAgent(event: AgentEvent): Long = mutex.withLock {
|
||||
val seq = outbox.reserveOffset()
|
||||
outbox.append(CommonEvent.Agent(date = event.date, offset = seq, event = event))
|
||||
seq
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -15,7 +15,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry
|
||||
import pw.binom.agentik.context.ContextStore
|
||||
import pw.binom.agentik.llm.tools.LlmReflector
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.outbox.DurableEvent
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -36,7 +36,7 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
* Подписка идёт через outbox (а не через per-conversation BackgroundEventBus
|
||||
* который был раньше): outbox — единый канал для всех событий (как клиентских,
|
||||
* так и внутренних), persistent tail с TTL работает из коробки, а клиенты
|
||||
* по тому же потоку могут самостоятельно видеть/логировать [Event.ToolFailed]
|
||||
* по тому же потоку могут самостоятельно видеть/логировать [DurableEvent.ToolFailed]
|
||||
* без скрытой телеметрии.
|
||||
*/
|
||||
internal data class ReflectionConfig(
|
||||
@@ -68,11 +68,11 @@ internal class ReflectionScheduler(
|
||||
merge(
|
||||
eventStore.events(after = null)
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.filter { it.event is Event.ConversationClosing && it.conversationId == conversationIdProvider() }
|
||||
.filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() }
|
||||
.onEach { onClosing() },
|
||||
eventStore.events(after = null)
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.filter { it.event is Event.ToolFailed && it.conversationId == conversationIdProvider() }
|
||||
.filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() }
|
||||
.onEach { onToolFailure() },
|
||||
).collect {}
|
||||
}
|
||||
|
||||
+44
-26
@@ -4,7 +4,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import mu.KotlinLogging
|
||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||
import pw.binom.agentik.outbox.DurableEvent as ProtoEvent
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.journal.MutableJournalStore as MutableJournalStore
|
||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||
@@ -37,19 +37,28 @@ internal class ToolDispatcher(
|
||||
val nowTs = now()
|
||||
val startMs = System.currentTimeMillis()
|
||||
|
||||
events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson))
|
||||
|
||||
if (!state.isTemporal) {
|
||||
messageStore.append(
|
||||
MessageRecord.ToolCall(
|
||||
id = callId,
|
||||
conversationId = state.id,
|
||||
toolName = call.name,
|
||||
toolTitle = null,
|
||||
toolArgsJson = argsJson,
|
||||
createdAt = nowTs,
|
||||
),
|
||||
// State-first: journal-запись и парное событие под одним offset'ом.
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
messageStore.append(
|
||||
MessageRecord.ToolCall(
|
||||
id = callId,
|
||||
conversationId = state.id,
|
||||
toolName = call.name,
|
||||
toolTitle = null,
|
||||
toolArgsJson = argsJson,
|
||||
createdAt = nowTs,
|
||||
seq = seq,
|
||||
),
|
||||
)
|
||||
},
|
||||
event = {
|
||||
ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson))
|
||||
}
|
||||
|
||||
val toolDeferred = state.agentScope.async {
|
||||
@@ -87,7 +96,29 @@ internal class ToolDispatcher(
|
||||
}
|
||||
|
||||
val resultAt = now()
|
||||
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
|
||||
if (!state.isTemporal) {
|
||||
// State-first: journal-запись и парное событие под одним offset'ом.
|
||||
events.commit(
|
||||
writeState = { seq ->
|
||||
messageStore.append(
|
||||
MessageRecord.ToolResult(
|
||||
id = resultId,
|
||||
conversationId = state.id,
|
||||
toolCallId = callId,
|
||||
toolName = call.name,
|
||||
result = resultText,
|
||||
createdAt = resultAt,
|
||||
seq = seq,
|
||||
),
|
||||
)
|
||||
},
|
||||
event = {
|
||||
ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
|
||||
}
|
||||
|
||||
// Только реальные падения тула попадают в outbox как background-event
|
||||
// (reflection-подобные потребители). Cancellation — not a failure,
|
||||
@@ -105,19 +136,6 @@ internal class ToolDispatcher(
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.isTemporal) {
|
||||
messageStore.append(
|
||||
MessageRecord.ToolResult(
|
||||
id = resultId,
|
||||
conversationId = state.id,
|
||||
toolCallId = callId,
|
||||
toolName = call.name,
|
||||
result = resultText,
|
||||
createdAt = resultAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return WorkingMemoryEntry.ToolExchange(
|
||||
sourceMessageId = callId,
|
||||
toolName = call.name,
|
||||
|
||||
+50
-12
@@ -6,20 +6,24 @@ import pw.binom.agentik.journal.MutableJournalStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
|
||||
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
|
||||
import pw.binom.agentik.journal.ksqlite.Schema as JournalSchema
|
||||
import pw.binom.agentik.outbox.OffsetSequencer
|
||||
import pw.binom.agentik.outbox.PersistentOffsetSequencer
|
||||
import pw.binom.agentik.outbox.ksqlite.KsqliteCursorStore
|
||||
import pw.binom.agentik.reflection.ReflectionStore
|
||||
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
|
||||
/**
|
||||
* Bundle из 4 ksqlite-сторов для standalone-агента.
|
||||
* Bundle из ksqlite-сторов для standalone-агента.
|
||||
*
|
||||
* Internal helper `:standalone` — bundle нужен только агенту, поэтому не
|
||||
* торчит наружу через публичный API модуля. Каждый store (conversation,
|
||||
* message, working_memory, reflection) живёт в своём ksqlite-модуле;
|
||||
* этот класс собирает их вокруг одной shared-connection и закрывает их
|
||||
* в правильном порядке в [close].
|
||||
* message, working_memory, reflection, outbox cursor) живёт в своём
|
||||
* ksqlite-модуле; этот класс собирает их вокруг одной shared-connection и
|
||||
* закрывает их в правильном порядке в [close].
|
||||
*
|
||||
* Lifecycle: открывает [SQLiteConnection] и возвращает 4 store'а. Каждый
|
||||
* Lifecycle: открывает [SQLiteConnection] и возвращает сторы. Каждый
|
||||
* store сам прогоняет свою схему в конструкторе (`Schema.migrate(connection)`
|
||||
* — idempotent `CREATE TABLE IF NOT EXISTS`), явных вызовов миграции в bundle
|
||||
* нет. Caller ДОЛЖЕН вызвать [close] при завершении.
|
||||
@@ -34,6 +38,12 @@ internal class SqliteStores internal constructor(
|
||||
val messages: MutableJournalStore,
|
||||
val workingMemory: ContextStore,
|
||||
val reflections: ReflectionStore,
|
||||
private val outboxCursorStore: KsqliteCursorStore,
|
||||
/**
|
||||
* Персистентный счётчик событий агента (см. [PersistentOffsetSequencer]).
|
||||
* Передаётся в `ChatAgent`, чтобы offset'ы переживали рестарт процесса.
|
||||
*/
|
||||
val outboxSequencer: OffsetSequencer,
|
||||
) : AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
@@ -41,6 +51,7 @@ internal class SqliteStores internal constructor(
|
||||
messages.close()
|
||||
workingMemory.close()
|
||||
reflections.close()
|
||||
outboxCursorStore.close()
|
||||
connection.close()
|
||||
}
|
||||
|
||||
@@ -48,12 +59,39 @@ internal class SqliteStores internal constructor(
|
||||
fun open(path: String): SqliteStores = assemble(SQLiteConnection.open(path))
|
||||
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
|
||||
|
||||
private fun assemble(conn: SQLiteConnection): SqliteStores = SqliteStores(
|
||||
connection = conn,
|
||||
conversations = KsqliteMutableConversationStore(conn),
|
||||
messages = KsqliteJournalStore(conn),
|
||||
workingMemory = KsqliteContextStore(conn),
|
||||
reflections = KsqliteReflectionStore(conn),
|
||||
)
|
||||
private fun assemble(conn: SQLiteConnection): SqliteStores {
|
||||
val cursorStore = KsqliteCursorStore(conn)
|
||||
return SqliteStores(
|
||||
connection = conn,
|
||||
conversations = KsqliteMutableConversationStore(conn),
|
||||
messages = KsqliteJournalStore(conn),
|
||||
workingMemory = KsqliteContextStore(conn),
|
||||
reflections = KsqliteReflectionStore(conn),
|
||||
outboxCursorStore = cursorStore,
|
||||
outboxSequencer = PersistentOffsetSequencer(
|
||||
store = cursorStore,
|
||||
initialNext = { seedNextFromJournal(conn) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Стартовая позиция счётчика при первом создании [KsqliteCursorStore],
|
||||
* когда в БД уже есть сообщения (апгрейд): `MAX(seq) + 1`.
|
||||
*
|
||||
* Без этого новые offset'ы начинались бы с 0 и столкнулись бы с уже
|
||||
* записанными `seq` журнала (см. [PersistentOffsetSequencer]).
|
||||
*/
|
||||
private fun seedNextFromJournal(conn: SQLiteConnection): Long {
|
||||
conn.prepare(
|
||||
"SELECT COALESCE(MAX(${JournalSchema.COL_SEQ}), -1) " +
|
||||
"FROM ${JournalSchema.TABLE_MESSAGE}"
|
||||
).use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
check(rs.next()) { "MAX(seq) must return a row" }
|
||||
return (rs.getLong(0) ?: -1L) + 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+73
-9
@@ -2,21 +2,20 @@ package pw.binom.agentik.standalone.agent
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.OnlineEvent
|
||||
import pw.binom.agentik.content.Content
|
||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||
import pw.binom.agentik.outbox.DurableEvent as ProtoEvent
|
||||
import pw.binom.agentik.skill.mining.SkillReadTool
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||
import pw.binom.agentik.standalone.persistence.SqliteStores
|
||||
import pw.binom.litert.LiteContentPart
|
||||
@@ -339,7 +338,7 @@ class ChatAgentTest {
|
||||
|
||||
val events = mutableListOf<ProtoEvent>()
|
||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
|
||||
}
|
||||
val online = mutableListOf<OnlineEvent>()
|
||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
@@ -381,7 +380,7 @@ class ChatAgentTest {
|
||||
val durable = mutableListOf<ProtoEvent>()
|
||||
val online = mutableListOf<OnlineEvent>()
|
||||
val durableJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { durable.add(it.event) }
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { durable.add(it.event) }
|
||||
}
|
||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
|
||||
@@ -421,7 +420,7 @@ class ChatAgentTest {
|
||||
// отправки событий подписка ничего не увидит.
|
||||
val events = mutableListOf<ProtoEvent>()
|
||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
|
||||
}
|
||||
val online = mutableListOf<OnlineEvent>()
|
||||
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
@@ -485,7 +484,7 @@ class ChatAgentTest {
|
||||
// Подписываемся ДО send — SharedFlow без replay
|
||||
val events = mutableListOf<ProtoEvent>()
|
||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
|
||||
}
|
||||
|
||||
val sendJob = launch {
|
||||
@@ -649,7 +648,7 @@ class ChatAgentTest {
|
||||
// Agent.events() удалён из :proto — события живут в
|
||||
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
|
||||
// распаковываем .event для получения AgentEvent.
|
||||
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { events.add(it.event) }
|
||||
agent.outbox.agentEvents(agent.outbox.oldestCursor()).collect { events.add(it.event) }
|
||||
}
|
||||
val conv = agent.createConversation(temp = false)
|
||||
agent.deleteConversation(conv.id)
|
||||
@@ -662,6 +661,71 @@ class ChatAgentTest {
|
||||
assertEquals(conv.id, created.conversationId)
|
||||
assertEquals(conv.id, deleted.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chatSnapshot returns state plus cursor and resuming from it loses nothing`() = runTest {
|
||||
// Инвариант протокола: snapshot.messages ∪ дельты(offset > cursor) ==
|
||||
// финальное состояние. Ни одна строка не теряется и не дублируется
|
||||
// (дедуп делается по id у клиента, но здесь проверяем само покрытие).
|
||||
val agent = newAgent()
|
||||
fakeLlm.reply = "first reply"
|
||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||
conv.send(listOf(Content.Text("first user")))
|
||||
|
||||
val snap = agent.chatSnapshot(conv.id)
|
||||
assertEquals(agent.outbox.currentCursor(), snap.cursor, "snapshot cursor must equal current cursor")
|
||||
assertTrue(snap.messages.all { it.seq <= snap.cursor.offset }, "message beyond cursor: ${snap.messages}")
|
||||
assertTrue(snap.messages.any { it is MessageRecord.UserMessage })
|
||||
assertTrue(snap.messages.any { it is MessageRecord.AssistantMessage })
|
||||
|
||||
// Резюм строго «после курсора» — следующая дельта.
|
||||
val deltas = mutableListOf<CommonEvent.Conversation>()
|
||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.conversationEvents(after = snap.cursor, conversationId = conv.id)
|
||||
.collect { deltas.add(it) }
|
||||
}
|
||||
fakeLlm.reply = "second reply"
|
||||
conv.send(listOf(Content.Text("second user")))
|
||||
delay(50)
|
||||
job.cancel()
|
||||
|
||||
assertTrue(deltas.isNotEmpty(), "no deltas after snapshot cursor")
|
||||
assertTrue(deltas.all { it.offset > snap.cursor.offset }, "delta <= cursor: $deltas")
|
||||
|
||||
val final = agent.chatSnapshot(conv.id)
|
||||
assertTrue(final.messages.size > snap.messages.size, "resume must advance state")
|
||||
val snapshotIds = snap.messages.map { it.id }.toSet()
|
||||
val deltaIds = deltas.mapNotNull { ce ->
|
||||
when (val e = ce.event) {
|
||||
is ProtoEvent.UserMessage -> e.id
|
||||
is ProtoEvent.AssistantMessage -> e.id
|
||||
else -> null
|
||||
}
|
||||
}.toSet()
|
||||
assertEquals(final.messages.map { it.id }.toSet(), snapshotIds + deltaIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationsSnapshot carries cursor and a later chat arrives as an agent delta`() = runTest {
|
||||
val agent = newAgent()
|
||||
val conv1 = agent.createConversation(temp = false)
|
||||
val snap = agent.conversationsSnapshot()
|
||||
assertEquals(agent.outbox.currentCursor(), snap.cursor)
|
||||
assertTrue(snap.conversations.any { it.id == conv1.id }, "snapshot=$snap")
|
||||
|
||||
val deltas = mutableListOf<AgentEvent>()
|
||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
agent.outbox.agentEvents(after = snap.cursor).collect { deltas.add(it.event) }
|
||||
}
|
||||
val conv2 = agent.createConversation(temp = false)
|
||||
delay(50)
|
||||
job.cancel()
|
||||
|
||||
assertTrue(
|
||||
deltas.any { it is AgentEvent.Created && it.conversationId == conv2.id },
|
||||
"deltas=$deltas",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */
|
||||
|
||||
Reference in New Issue
Block a user