Добавляет 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:
2026-10-02 01:16:15 +03:00
parent 92b76c4e3c
commit 5bdc517988
63 changed files with 2953 additions and 1017 deletions
+3
View File
@@ -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,
@@ -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) {
@@ -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)
@@ -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
}
}
@@ -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 {}
}
@@ -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,
@@ -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
}
}
}
}
}
@@ -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. */