refactor: outbox становится live-only стримом, cursor вынесен в :cursor-api

Семантика outbox'а — теперь чистый live-канал:
  * OutboxStore.conversationEvents(id) — больше не принимает after-cursor.
    Catchup (replay) делает клиент: journal.listFlow(afterSeq) + подписка
    на live. Outbox ответственен только за уведомления «что-то произошло».
  * OutboxStore.oldestCursor()/currentCursor()/OutboxGapException — удалены.
    Эпоха и offset живут ТОЛЬКО в CursorHolder/CursorStore, переживают
    рестарт и инкремент для каждого commit.
  * DurableEvent: commit принимает блок { cursor -> MessageRecord }
    (cursor выдаёт CursorStore; клиент не вычисляет offset сам).
  * MessageRecord больше не несёт cursor — это не его ответственность.

Новые модули:
  * :cursor-api         — Cursor(epoch, offset) + CursorHolder / MutableCursorHolder
  * :cursor-ksqlite     — KsqliteCursorHolder (персистентный)
  * :cursor-inmemory    — для тестов
  * :client-sync        — LocalSyncAgent (мини-агент поверх :client для десктопа)

Удалены:
  * :sync-core          — старая референсная реализация, заменена
                           cursor-разделением и :client.
  * outbox-ksqlite      — CursorStore/Schema уехали в :cursor-ksqlite.
  * OffsetSequencer / PersistentOffsetSequencer / InMemoryOffsetSequencer.

standalone:
  * ChatAgent/ConversationLoop/DurableLog/ToolDispatcher/ReflectionScheduler/
    ConversationEvents — подписка через push-паттерн (collect событий).
  * A2aBridge — currentCursor() и conversationEvents(after=) убраны.
  * SqliteStores — cursor_offset удалён из schema v4; seedNextFromJournal
    читает MAX(created_at).
  * Main.kt — outboxSequencer → outboxCursorHolder; user→agent (:server)
    transport удалён; debug-routes удалены; A2A остался.
  * Тесты ChatAgentTest/PersistenceTest переписаны на push-паттерн
    (subscribe-before-act, snapshot∪live = итоговое состояние). 25/25 + 19/19 ✅

server / client:
  * Routes эпоху читают из CursorHolder; снимки несут Cursor? для catchup.
  * AgentikAgent и HttpEventStore — те же подписки, без after-параметра.
  * ReconnectingOutbox / ReconnectingOutboxTest — без изменений API.
  * JournalStore API расширен count(after=Instant?) для unread-badge.
This commit is contained in:
2026-10-05 23:33:35 +03:00
parent 22a167ed03
commit 9e888227a3
115 changed files with 2147 additions and 6025 deletions
@@ -47,9 +47,11 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
.joinToString("\n") { it.text }
val conv = resolveConversation(contextId)
// Курсор старта: снапшот не нужен, достаточно текущей позиции лога.
val since = agent.outbox.currentCursor()
val reply = StringBuilder()
// Live-подписки без catchup (snapshots выдаются outbox'ом отдельно,
// см. Agent.chatSnapshot / conversationsSnapshot). Терминатор
// хода — [Event.AssistantMessage] (успех) / [Event.Interrupted] /
// [Event.Error] (провал).
val reply = StringBuilder()
val turnDone = CompletableDeferred<Unit>()
// Онлайн-поток — дельты ответа (live-only, без catchup).
val onlineJob = async {
@@ -61,11 +63,9 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
}
}
}
// Durable-поток — терминатор хода (catchup + live): целый ответ
// приходит [Event.AssistantMessage], обрыв — [Event.Interrupted],
// провал — [Event.Error].
// Durable-поток — терминатор хода (live-only).
val turnJob = async {
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
agent.outbox.conversationEvents(conv.id).collect { ce ->
when (val e = ce.event) {
is DurableEvent.AssistantMessage, is DurableEvent.Interrupted -> turnDone.complete(Unit)
is DurableEvent.Error ->
@@ -1,141 +0,0 @@
package pw.binom.agentik.standalone
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.standalone.agent.ChatConversation
import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.standalone.agent.memory.Curator
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.context.ContextStore
/**
* Debug-эндпоинты для ручного триггерирования фоновых фич (без ожидания
* интервалов). Подключаются только при `AGENTIK_DEBUG_ENDPOINTS=1`:
*
* - `POST /debug/reflect?conversationId=...` — прогон [LlmReflector] прямо сейчас
* - `POST /debug/skill-mine?conversationId=...` — прогон [SkillMiner] прямо сейчас
* - `POST /debug/curate` — прогон [Curator.runPass] прямо сейчас
* - `POST /debug/compact?conversationId=...` — принудительный compaction
*
* Token-stats эндпоинт убран вместе с `MessageStore.tokenStats()` (см.
* :message-log-api/MessageStore.kt). Сейчас token accounting доступен
* только через assistant-сообщения с `TurnTokens` (см. MessageRecord).
*
* Каждый возвращает JSON с результатом (что сохранил / нашёл / сжал), чтобы в
* тестах было видно не только "триггер сработал", а что именно LLM намайнила.
* Только локальная отладка: endpoint'ы без авторизации, в проде не включать.
*/
internal fun Route.debugRoutes(
agent: Agent,
workingMemoryStore: ContextStore,
reflectionStore: ReflectionStore,
reflector: LlmReflector?,
skillMiner: SkillMiner?,
skillStore: SkillStore?,
curator: Curator?,
) {
post("/debug/reflect") {
val convId = call.parameters["conversationId"]
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
val minerReflector = reflector
if (minerReflector == null) return@post call.respondText("reflection disabled", status = HttpStatusCode.NotFound)
val turns = recentTurns(workingMemoryStore, convId, maxTurns = 6)
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
val reflection = minerReflector.reflect(turns)
if (reflection == null) {
call.respondText("""{"reflected":false,"reason":"unparseable LLM reply"}""", contentType = ContentType.Application.Json)
} else {
val stamped = reflection.copy(conversationId = convId)
reflectionStore.insert(stamped)
call.respondText(
buildJsonObject {
put("reflected", true)
put("id", stamped.id)
put("score", stamped.score.toString())
put("summary", stamped.summary)
put("weakSpots", kotlinx.serialization.json.JsonArray(stamped.weakSpots.map { kotlinx.serialization.json.JsonPrimitive(it) }))
}.toString(),
contentType = ContentType.Application.Json,
)
}
}
post("/debug/skill-mine") {
val convId = call.parameters["conversationId"]
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
val miner = skillMiner
val store = skillStore
if (miner == null || store == null) return@post call.respondText("skill mining disabled", status = HttpStatusCode.NotFound)
val turns = recentTurns(workingMemoryStore, convId, maxTurns = miner.maxTurns)
if (turns.isEmpty()) return@post call.respondText("no turns in conversation", status = HttpStatusCode.NotFound)
val mined = miner.mine(turns, store.catalog.skills)
for (s in mined) store.upsert(s)
val json = buildJsonObject {
put("mined", mined.size.toString())
put(
"skills",
kotlinx.serialization.json.JsonArray(
mined.map { s ->
kotlinx.serialization.json.buildJsonObject {
put("name", s.name)
put("description", s.description)
}
},
),
)
}.toString()
call.respondText(json, contentType = ContentType.Application.Json)
}
post("/debug/curate") {
val c = curator ?: return@post call.respondText("curator disabled (memory off?)", status = HttpStatusCode.NotFound)
val archived = c.runPass()
call.respondText("""{"archived":$archived}""", contentType = ContentType.Application.Json)
}
post("/debug/compact") {
val convId = call.parameters["conversationId"]
?: return@post call.respondText("conversationId required", status = HttpStatusCode.BadRequest)
val conv = agent.getConversation(convId) ?: return@post call.respondText("conversation not found", status = HttpStatusCode.NotFound)
val chatConv = conv as? ChatConversation ?: return@post call.respondText("not a ChatConversation", status = HttpStatusCode.InternalServerError)
val ok = chatConv.forceCompactNow()
call.respondText("""{"compacted":$ok}""", contentType = ContentType.Application.Json)
}
}
/**
* Последние [maxTurns] пар user/assistant из working memory диалога
* (для debug-триггеров reflector/miner; та же логика, что у хуков
* [ChatConversation]).
*/
internal suspend fun recentTurns(workingMemoryStore: ContextStore, conversationId: String, maxTurns: Int): List<ConversationTurn> {
val rows = workingMemoryStore.list(conversationId)
val pairs = mutableListOf<ConversationTurn>()
var pendingUser: String? = null
for (row in rows) {
when (val e = row.entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = e.content.text()
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> {
val user = pendingUser ?: ""
pendingUser = null
pairs += ConversationTurn(userMessage = user, assistantMessage = e.content.text())
}
else -> {}
}
}
return pairs.takeLast(maxTurns)
}
/** Текстовое содержимое записей working memory (Text-контент, без картинок). */
internal fun List<pw.binom.agentik.content.Content>.text(): String =
filterIsInstance<pw.binom.agentik.content.Content.Text>().joinToString("\n") { it.body }
@@ -19,7 +19,6 @@ import pw.binom.agentik.memory.mdvector.openHybridMemorySystem
import pw.binom.agentik.memory.vector.VectorMemorySystem
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
import pw.binom.agentik.server.agentikAgent
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.standalone.agent.ChatAgent
import pw.binom.agentik.llm.tools.LiteLlmContextCompactor
@@ -30,31 +29,30 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.ModelDownloader
import pw.binom.agentik.mcp.bridge.McpRegistry
import pw.binom.agentik.integrations.telegram.TelegramBridgeComponent
import pw.binom.agentik.integrations.telegram.createTelegramBridgeComponent
import pw.binom.agentik.standalone.persistence.SqliteStores
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.io.File
import pw.binom.agentik.skill.mining.SkillMiner
/**
* standalone-контейнер agentik:
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
* POST /agentik/conversations -> ConversationSnapshot (201)
* GET /agentik/conversations -> [ConversationSnapshot]
* GET /agentik/conversations/{id} -> ConversationSnapshot
* PATCH /agentik/conversations/{id} -> ConversationSnapshot
* DELETE /agentik/conversations/{id} -> 204
* POST /agentik/conversations/{id}/messages -> 202
* POST /agentik/conversations/{id}/interrupt -> 202
* GET /agentik/conversations/{id}/messages -> [Message]
* GET /agentik/conversations/{id}/events -> text/event-stream (SSE)
* GET /agentik/events -> text/event-stream (SSE, Agent-level)
* POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)
* GET /a2a/.well-known/agent-card.json -> AgentCard
* GET /health -> "ok"
* standalone-контейнер agentik: локальный агент с A2A-фасадом.
*
* Транспорт user↔agent (`:server` proto-фасад) из standalone удалён. Остаётся
* встраиваемый Ktor (CIO), на котором смонтирован только A2A-фасад:
* - GET /health -> "ok"
* - POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)
* - GET /a2a/.well-known/agent-card.json -> AgentCard
* MCP-клиент (исходящий) и локальный агент — на месте; локальный транспорт
* (IPC/stdio) добавим отдельно позже.
*
* Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
* - AGENTIK_PORT / AGENTIK_DB_PATH
* - AGENTIK_TOKEN — Bearer-токен для HTTP-фасада /agentik (пусто — авторизация выключена)
* - AGENTIK_A2A_TOKEN — Bearer-токен для A2A-фасада /a2a (пусто — авторизация выключена)
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
* - Skills: AGENTIK_SKILLS_DIR=<path> (папка с SKILL.md / *.yaml)
@@ -88,7 +86,7 @@ private fun printHelp() {
println(
"""
agentik standalone — usage:
java -jar agentik.jar Start HTTP server (A2A + :proto)
java -jar agentik.jar Start standalone (A2A agent facade)
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
""".trimIndent()
)
@@ -394,9 +392,32 @@ private fun runServer() {
reflector = reflector,
// Персистентный счётчик событий: offset/epoch переживают рестарт,
// клиенты продолжают инкрементально, а не делают полный resync.
outboxSequencer = sqliteStores.outboxSequencer,
outboxCursorHolder = sqliteStores.outboxCursorHolder,
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
// Telegram-интеграция. Подключается ТОЛЬКО при заданном
// `AGENTIK_TELEGRAM_TOKEN` — иначе standalone ведёт себя как раньше.
// Клиент (`HttpClient` + Ktor CIO) создаётся фабрикой
// `createTelegramBridgeComponent` и закрывается shutdown-hook'ом ниже,
// перед `agent.close()`. Polling/стрим-джобы живут на отдельном
// `integrationsScope` — он отменяется ДО `agent.close()` тем же
// shutdown-hook'ом (см. ниже), чтобы polling long-poll корректно
// прервался и не блокировал JVM-выход.
val integrationsScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val tgToken = config.integrations.telegram.token
val tgClient: AutoCloseable? = if (!tgToken.isNullOrBlank()) {
val (client, component) = createTelegramBridgeComponent(
token = tgToken,
scope = integrationsScope,
connection = sqliteStores.connection,
config = config.integrations.telegram,
)
agent.install(component)
client
} else {
null
}
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
// чтобы debug-эндпоинты могли его триггерить вручную.
val curator: pw.binom.agentik.standalone.agent.memory.Curator? =
@@ -407,38 +428,30 @@ private fun runServer() {
c
} else null
// A2A-фасад (agent↔agent). Proto-фасад user↔agent удалён.
val server = embeddedServer(CIO, port = config.agent.port) {
routing {
get("/health") { call.respondText("ok") }
agentikAgent(agent, path = "/agentik", token = config.agent.authToken)
a2aAgent(
agentName = "agentik",
handler = A2aBridge(agent),
path = "/a2a",
token = config.agent.a2aToken,
)
if (config.debug.endpoints) {
debugRoutes(
agent = agent,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
reflector = reflector,
skillMiner = skillMiner,
skillStore = skillStore,
curator = curator,
)
}
}
}
println("agentik standalone listening on http://localhost:${config.agent.port}")
println(" GET /health")
println(" POST /agentik/conversations -> 201")
println(" GET /agentik/conversations/{id}/events -> SSE")
println(" POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)")
println(" GET /a2a/.well-known/agent-card.json -> AgentCard")
println(" storage: ${config.agent.dbPath}")
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
if (tgToken.isNullOrBlank()) {
println(" telegram: disabled (AGENTIK_TELEGRAM_TOKEN not set)")
} else {
println(" telegram: enabled (polling timeout=${config.integrations.telegram.pollingTimeoutSec}s, persistent=${config.integrations.telegram.persistentConversations})")
}
println(" skills: ${skills.size} loaded${config.agent.skillsDir?.let { " from $it" } ?: ""}")
if (config.agent.soulPath != null) println(" soul: ${config.agent.soulPath} (${soulBody?.length ?: 0} chars)")
println(" memory: ${if (memorySystem == null) "disabled" else "${config.memory.backend.name.lowercase()}-backend"}")
@@ -453,9 +466,6 @@ private fun runServer() {
if (skillMiner != null) {
println(" skill-mining: enabled (interval=${config.skillMining.interval} turns, maxTurns=${config.skillMining.maxTurns})")
}
if (config.debug.endpoints) {
println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)")
}
// Раньше здесь был агрегат tokenStats() по всем conv'ам при старте. Метод
// убран из :message-log-api (MessageStore стал чисто read-only list+listFlow);
// см. agentik :message-log-api/MessageStore.kt. Token accounting теперь
@@ -465,7 +475,11 @@ private fun runServer() {
println(" conversations: ${existingConvs.size} (active)")
}
Runtime.getRuntime().addShutdownHook(Thread {
// Сначала отменяем integrations (polling/stream-джобы) — иначе
// длинный Telegram-long-poll (до 30с) будет держать JVM.
integrationsScope.cancel()
agent.close()
tgClient?.close()
mcpRegistry.close()
})
Runtime.getRuntime().addShutdownHook(Thread {
@@ -31,8 +31,8 @@ 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.cursor.CursorHolder
import pw.binom.agentik.cursor.inmemory.InMemoryCursorHolder
import pw.binom.agentik.outbox.OnlineOutbox
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
@@ -150,11 +150,11 @@ internal class ChatAgent(
/**
* Счётчик событий агента. По умолчанию in-memory: offset'ы начинаются с 0
* при каждом старте процесса. Production передаёт
* [pw.binom.agentik.outbox.PersistentOffsetSequencer] поверх ksqlite
* (`SqliteStores.outboxSequencer`), чтобы `offset`/`epoch` переживали
* [pw.binom.agentik.cursor.ksqlite.KsqliteCursorHolder] поверх ksqlite
* (`SqliteStores.outboxCursorHolder`), чтобы `offset`/`epoch` переживали
* рестарт и клиент продолжал инкрементально (см. [Cursor]).
*/
private val outboxSequencer: OffsetSequencer = InMemoryOffsetSequencer(),
private val outboxCursorHolder: CursorHolder = InMemoryCursorHolder(),
) : MutableAgent, AutoCloseable {
/**
@@ -218,18 +218,14 @@ private val testTools: MutableList<LiteTool> = mutableListOf()
* **Live tail + auto-TTL** — клиенты больше не должны заботиться о persistence
* или подписке на два отдельных канала.
*/
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore(
maxMessages = null,
ttl = null,
sequencer = outboxSequencer,
)
private val eventStore: MutableOutboxStore = pw.binom.agentik.outbox.inmemory.InMemoryOutboxStore()
/**
* Сериализатор durable-записей: reserve offset → state → event (см. [DurableLog]).
* Делит [eventStore] со всеми беседами агента, поэтому offset'ы сквозные
* по агенту (и по всем беседам сразу).
*/
private val durableLog: DurableLog = DurableLog(eventStore)
private val durableLog: DurableLog = DurableLog(eventStore, outboxCursorHolder)
/**
* Live-канал стриминга ответа (дельты текста/картинок и фазовые маркеры).
@@ -319,7 +315,7 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
* Оба маппатся в один [Flow], `SkillMiningComponent` сам решает что делать.
*/
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
eventStore.events(after = null)
eventStore.events()
.filterIsInstance<CommonEvent.Conversation>()
.mapNotNull { ce ->
when (val e = ce.event) {
@@ -574,13 +570,17 @@ private val onlineEventStore: MutableOnlineOutbox = pw.binom.agentik.outbox.inme
override suspend fun chatSnapshot(conversationId: String): ChatSnapshot {
val cursor = durableLog.currentCursor()
val out = ArrayList<MessageRecord>()
var afterSeq = -1L
// В новой модели (v4) журнал адресуется по `createdAt: Instant` —
// нет seq/cursor_offset. Стартуем от «начала времён» и пагинируем
// смещением (`offset: Int`).
var after: Instant = Instant.fromEpochMilliseconds(0L)
var offset = 0
while (true) {
val page = messageStore.list(conversationId, afterSeq, cursor.offset, JournalStore.PAGE_SIZE)
val page = messageStore.list(conversationId, after, offset, JournalStore.PAGE_SIZE)
if (page.isEmpty()) break
out += page
afterSeq = page.last().seq
if (page.size < JournalStore.PAGE_SIZE) break
offset += page.size
}
return ChatSnapshot(conversationId = conversationId, messages = out, cursor = cursor)
}
@@ -3,7 +3,7 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.outbox.Cursor
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.outbox.DurableEvent
import pw.binom.agentik.outbox.MutableOnlineOutbox
import pw.binom.agentik.outbox.OnlineEvent
@@ -14,7 +14,18 @@ import pw.binom.agentik.outbox.OnlineEvent
* - online ([OnlineEvent]) → [onlineStore], live-only (без catchup).
*
* Все durable-эмиссии идут через [DurableLog], чтобы state-row и парное
* событие получали один offset (см. KDoc [DurableLog]).
* событие появлялись синхронно (см. KDoc [DurableLog]).
*
* ## Catchup через [Cursor]
*
* В новой модели outbox — чисто live-flow ([OutboxStore.events]), без
* replay. Для resume подписчик держит свой [Cursor] и запрашивает
* снапшот через [pw.binom.agentik.proto.Agent.chatSnapshot] /
* [conversationsSnapshot][pw.binom.agentik.proto.Agent.conversationsSnapshot];
* потом прикладывает live-события, **сравнивая cursor** с тем, что идёт в
* журнале. См. [events] — cold-flow от текущего outbox без фильтра
* `after`: фильтрация делается на стороне подписчика, потому что outbox
* уже выдал реплея.
*/
internal class ConversationEvents(
private val durableLog: DurableLog,
@@ -23,9 +34,9 @@ internal class ConversationEvents(
) {
/**
* Атомарная durable-запись: сначала [writeState] (journal/...) с
* забронированным `seq`, затем парное событие [event] с тем же offset.
* забронированным `cursor`, затем парное событие [event] с тем же cursor.
*/
suspend fun <T> commit(writeState: suspend (seq: Long) -> T, event: (seq: Long) -> DurableEvent): T =
suspend fun <T> commit(writeState: suspend (cursor: Cursor) -> T, event: (cursor: Cursor) -> DurableEvent): T =
durableLog.commit(conversationId = conversationId, writeState = writeState, event = event)
/** Durable-событие без парной записи состояния. */
@@ -43,8 +54,14 @@ internal class ConversationEvents(
return true
}
fun events(after: Cursor?): Flow<DurableEvent> =
durableLog.outbox.conversationEvents(after = after, conversationId = conversationId)
/**
* Cold-flow durable-событий диалога. В новой модели outbox не
* фильтрует по `after` (нет replay) — подписчик держит свой
* [Cursor] и применяет дельты сам. Возвращает все события диалога,
* эмитированные **после** вызова [events].
*/
fun events(): Flow<DurableEvent> =
durableLog.outbox.conversationEvents(conversationId = conversationId)
.map { it.event }
/** Best-effort эмиссия онлайн-события — без блокировки продюсера и без хранения. */
@@ -195,8 +195,8 @@ class ConversationLoop(
// State-first: journal.append + workingMemory под одним offset'ом,
// затем парное durable-событие (см. DurableLog).
events.commit(
writeState = { seq ->
messageStore.append(userRecord.copy(seq = seq))
writeState = { _ ->
messageStore.append(userRecord)
workingMemoryStore.append(
conversationId = id,
entry = WorkingMemoryEntry.User(
@@ -465,7 +465,7 @@ class ConversationLoop(
// State-first: journal.append под забронированным offset'ом,
// затем парное durable-событие.
events.commit(
writeState = { seq -> messageStore.append(assistantRecord.copy(seq = seq)) },
writeState = { _ -> messageStore.append(assistantRecord) },
event = {
ProtoEvent.AssistantMessage(
date = assistantAt,
@@ -533,7 +533,7 @@ class ConversationLoop(
val ts = now()
if (!state.isTemporal) {
events.commit(
writeState = { seq ->
writeState = { _ ->
messageStore.append(
MessageRecord.Error(
id = newId("err"),
@@ -541,7 +541,6 @@ class ConversationLoop(
message = message,
code = code,
createdAt = ts,
seq = seq,
),
)
},
@@ -2,40 +2,50 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.cursor.CursorHolder
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.
* парное ему outbox-событие появляются **синхронно**.
*
* ## Зачем нужен commit-lock
*
* Протокол снапшотов держится на трёх инвариантах (см. README `:outbox-api`):
* 1. **Producer**: сначала мутация состояния (journal/conversation), потом
* `append` события — оба с одинаковым `seq`/`offset`;
* 2. **Reader**: сначала `currentCursor()` (= последний **заапенденный**
* offset), потом чтение состояния с отсечкой `seq <= C`; всё, что `> C`,
* приедет потоком;
* `append` события — оба с одинаковым [Cursor], выданным из [CursorHolder];
* 2. **Reader**: сначала [currentCursor] (= следующий offset'ы **к выдаче**),
* потом чтение состояния с отсечкой по `created_at <= cursor.offset`;
* всё, что появилось позже, приедет потоком;
* 3. **Идемпотентность** применения (upsert/delete по id).
*
* Инвариант (1) ломается, если два продюсера параллельно забронируют
* offset'ы и запишут их в разном порядке (A резервирует 10, B — 11, B
* аппендит первым → outbox отвергнет 10 как немонотонный, либо состояние
* 10 «застрянет» ниже `currentCursor` и потеряется для подписчика).
* [commit]/[appendConversation]/[appendAgent] держат [Mutex] на всё время
* «reserve → запись состояния → append», поэтому durable-поток строго
* линеен, а `currentCursor` всегда указывает на корректную точку отсечки.
* offset'ы и запишут их в разном порядке. [commit] / [appendConversation] /
* [appendAgent] держат [Mutex] на всё время «reserve → запись состояния →
* append», поэтому durable-поток строго линеен, а [currentCursor] всегда
* указывает на корректную точку отсечки.
*
* ## Почему outbox больше НЕ ведает offset'ами
*
* До миграции в `:outbox-api` outbox сам владел счётчиком (`reserveOffset`
* / `currentCursor` / `offset` в `CommonEvent.Conversation`). После
* рефакторинга v4 outbox — просто live-шина уведомлений; монотонный
* `(createdAt, offset)` для удалённого resume — отдельная подсистема
* `:cursor-api` (см. [CursorHolder]). [DurableLog] — единственная точка в
* `ChatAgent`, которая сводит «journal/рабочая память ↔ outbox»: журнал
* получает [Cursor] в `MessageRecord`, outbox — лишь дату и сам факт.
*
* Живёт в `:standalone` (а не в `:outbox-api`), потому что знает про
* journal/conversation-сторы; outbox остаётся тупым хранилищем событий.
* journal/conversation-сторы; outbox остаётся тупым потоком событий.
*/
class DurableLog(
val outbox: MutableOutboxStore,
private val cursorHolder: CursorHolder,
) {
private val mutex = Mutex()
@@ -43,35 +53,35 @@ class DurableLog(
val events: OutboxStore get() = outbox
/** Точка отсечки для нового снапшота (см. протокол). */
suspend fun currentCursor(): Cursor = outbox.currentCursor()
suspend fun currentCursor(): Cursor = mutex.withLock { cursorHolder.current() }
/**
* Атомарно: забронировать offset → записать состояние с этим `seq` →
* Атомарно: забронировать курсор → записать состояние с этим [Cursor] →
* аппендить парное событие. Возвращает результат [writeState].
*/
suspend fun <T> commit(
conversationId: String,
writeState: suspend (seq: Long) -> T,
event: (seq: Long) -> DurableEvent,
writeState: suspend (cursor: Cursor) -> T,
event: (cursor: Cursor) -> 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))
val cursor = cursorHolder.next()
val result = writeState(cursor)
val e = event(cursor)
outbox.append(CommonEvent.Conversation(date = e.date, 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
suspend fun appendConversation(conversationId: String, event: DurableEvent): Cursor = mutex.withLock {
val cursor = cursorHolder.next()
outbox.append(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
cursor
}
/** Агент-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
suspend fun appendAgent(event: AgentEvent): Cursor = mutex.withLock {
val cursor = cursorHolder.next()
outbox.append(CommonEvent.Agent(date = event.date, event = event))
cursor
}
}
}
@@ -66,11 +66,11 @@ internal class ReflectionScheduler(
if (subscriptionJob?.isActive == true) return
subscriptionJob = scope.launch {
merge(
eventStore.events(after = null)
eventStore.events()
.filterIsInstance<CommonEvent.Conversation>()
.filter { it.event is DurableEvent.ConversationClosing && it.conversationId == conversationIdProvider() }
.onEach { onClosing() },
eventStore.events(after = null)
eventStore.events()
.filterIsInstance<CommonEvent.Conversation>()
.filter { it.event is DurableEvent.ToolFailed && it.conversationId == conversationIdProvider() }
.onEach { onToolFailure() },
@@ -40,7 +40,7 @@ internal class ToolDispatcher(
if (!state.isTemporal) {
// State-first: journal-запись и парное событие под одним offset'ом.
events.commit(
writeState = { seq ->
writeState = { _ ->
messageStore.append(
MessageRecord.ToolCall(
id = callId,
@@ -49,7 +49,6 @@ internal class ToolDispatcher(
toolTitle = null,
toolArgsJson = argsJson,
createdAt = nowTs,
seq = seq,
),
)
},
@@ -99,7 +98,7 @@ internal class ToolDispatcher(
if (!state.isTemporal) {
// State-first: journal-запись и парное событие под одним offset'ом.
events.commit(
writeState = { seq ->
writeState = { _ ->
messageStore.append(
MessageRecord.ToolResult(
id = resultId,
@@ -108,7 +107,6 @@ internal class ToolDispatcher(
toolName = call.name,
result = resultText,
createdAt = resultAt,
seq = seq,
),
)
},
@@ -6,6 +6,7 @@ import pw.binom.agentik.standalone.llm.GoogleConfig
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.integrations.telegram.TelegramConfig
import pw.binom.agentik.mcp.bridge.McpConfig
import pw.binom.agentik.proto.AgentInfo
@@ -37,6 +38,7 @@ data class AppConfig(
val embedding: EmbeddingSection,
val reflection: ReflectionSection,
val skillMining: SkillMiningSection,
val integrations: IntegrationsSection,
val debug: DebugSection,
) {
/** HTTP-сервер, БД, skills, soul. */
@@ -141,6 +143,18 @@ data class AppConfig(
val maxTurns: Int = DEFAULT_SKILL_MINING_MAX_TURNS,
)
/**
* Конфиги мессенджер-интеграций. Каждая секция читается из env
* (`AGENTIK_TELEGRAM_TOKEN`, …); пустой/незаданный токен ⇒ интеграция
* выключена, в Main.kt соответствующий Component не инстанцируется.
*
* См. `:integrations-telegram` / `TelegramBridgeComponent`.
*/
@Serializable
data class IntegrationsSection(
val telegram: TelegramConfig = TelegramConfig(),
)
/** Debug-эндпоинты (/debug/reflect, /debug/skill-mine, и т.д.). */
@Serializable
data class DebugSection(
@@ -270,6 +284,16 @@ data class AppConfig(
maxTurns = env("AGENTIK_SKILL_MINING_MAX_TURNS")?.toIntOrNull()
?.coerceIn(1, 1000) ?: DEFAULT_SKILL_MINING_MAX_TURNS,
),
integrations = IntegrationsSection(
telegram = TelegramConfig(
token = env("AGENTIK_TELEGRAM_TOKEN")?.takeIf { it.isNotBlank() },
pollingTimeoutSec = env("AGENTIK_TELEGRAM_POLLING_TIMEOUT")?.toIntOrNull()
?.coerceIn(0, 60) ?: TelegramConfig.DEFAULT_POLLING_TIMEOUT_SEC,
persistentConversations = env("AGENTIK_TELEGRAM_PERSISTENT")?.let {
it.equals("1", ignoreCase = true) || it.equals("true", ignoreCase = true)
} ?: TelegramConfig.DEFAULT_PERSISTENT_CONVERSATIONS,
),
),
debug = DebugSection(
endpoints = env("AGENTIK_DEBUG_ENDPOINTS")?.let {
it.equals("1", ignoreCase = true) || it.equals("true", ignoreCase = true)
@@ -2,14 +2,12 @@ package pw.binom.agentik.standalone.persistence
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.context.ksqlite.KsqliteContextStore
import pw.binom.agentik.cursor.ksqlite.KsqliteCursorHolder
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
@@ -38,12 +36,11 @@ internal class SqliteStores internal constructor(
val messages: MutableJournalStore,
val workingMemory: ContextStore,
val reflections: ReflectionStore,
private val outboxCursorStore: KsqliteCursorStore,
/**
* Персистентный счётчик событий агента (см. [PersistentOffsetSequencer]).
* Персистентный счётчик событий агента (см. [KsqliteCursorHolder]).
* Передаётся в `ChatAgent`, чтобы offset'ы переживали рестарт процесса.
*/
val outboxSequencer: OffsetSequencer,
val outboxCursorHolder: KsqliteCursorHolder,
) : AutoCloseable {
override fun close() {
@@ -51,7 +48,7 @@ internal class SqliteStores internal constructor(
messages.close()
workingMemory.close()
reflections.close()
outboxCursorStore.close()
outboxCursorHolder.close()
connection.close()
}
@@ -60,36 +57,37 @@ internal class SqliteStores internal constructor(
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
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,
outboxCursorHolder = KsqliteCursorHolder(
connection = conn,
initialNext = { seedNextFromJournal(conn) },
),
)
}
/**
* Стартовая позиция счётчика при первом создании [KsqliteCursorStore],
* когда в БД уже есть сообщения (апгрейд): `MAX(seq) + 1`.
* Стартовая позиция счётчика при первом создании [KsqliteCursorHolder],
* когда в БД уже есть сообщения (апгрейд): `MAX(created_at) + 1`.
*
* Без этого новые offset'ы начинались бы с 0 и столкнулись бы с уже
* записанными `seq` журнала (см. [PersistentOffsetSequencer]).
* Без этого новые offset'ы начинались бы с `1` и могли бы оказаться
* меньше уже записанных сообщений (в журнале v4 курсор-колонки
* удалены — `created_at` остался единственным монотонным
* идентификатором позиции, см. [pw.binom.agentik.journal.ksqlite.Schema.CURRENT_VERSION]).
* Для пустой БД возвращает `1` — первый выдаваемый offset.
*/
private fun seedNextFromJournal(conn: SQLiteConnection): Long {
private fun seedNextFromJournal(conn: SQLiteConnection): ULong {
conn.prepare(
"SELECT COALESCE(MAX(${JournalSchema.COL_SEQ}), -1) " +
"SELECT COALESCE(MAX(${JournalSchema.COL_CREATED_AT}), 0) + 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
check(rs.next()) { "MAX(created_at) must return a row" }
return (rs.getLong(0) ?: 1L).toULong()
}
}
}
@@ -338,7 +338,7 @@ class ChatAgentTest {
val events = mutableListOf<ProtoEvent>()
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
agent.outbox.conversationEvents(conv.id).collect { events.add(it.event) }
}
val online = mutableListOf<OnlineEvent>()
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
@@ -380,7 +380,7 @@ class ChatAgentTest {
val durable = mutableListOf<ProtoEvent>()
val online = mutableListOf<OnlineEvent>()
val durableJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { durable.add(it.event) }
agent.outbox.conversationEvents(conv.id).collect { durable.add(it.event) }
}
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.onlineOutbox.onlineEvents(conv.id).collect { online.add(it) }
@@ -420,7 +420,7 @@ class ChatAgentTest {
// отправки событий подписка ничего не увидит.
val events = mutableListOf<ProtoEvent>()
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
agent.outbox.conversationEvents(conv.id).collect { events.add(it.event) }
}
val online = mutableListOf<OnlineEvent>()
val onlineJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
@@ -484,7 +484,7 @@ class ChatAgentTest {
// Подписываемся ДО send — SharedFlow без replay
val events = mutableListOf<ProtoEvent>()
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.outbox.conversationEvents(agent.outbox.oldestCursor(), conv.id).collect { events.add(it.event) }
agent.outbox.conversationEvents(conv.id).collect { events.add(it.event) }
}
val sendJob = launch {
@@ -648,7 +648,7 @@ class ChatAgentTest {
// Agent.events() удалён из :proto — события живут в
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
// распаковываем .event для получения AgentEvent.
agent.outbox.agentEvents(agent.outbox.oldestCursor()).collect { events.add(it.event) }
agent.outbox.agentEvents().collect { events.add(it.event) }
}
val conv = agent.createConversation(temp = false)
agent.deleteConversation(conv.id)
@@ -663,60 +663,76 @@ class ChatAgentTest {
}
@Test
fun `chatSnapshot returns state plus cursor and resuming from it loses nothing`() = runTest {
// Инвариант протокола: snapshot.messages ∪ дельты(offset > cursor) ==
// финальное состояние. Ни одна строка не теряется и не дублируется
// (дедуп делается по id у клиента, но здесь проверяем само покрытие).
fun `chatSnapshot returns full state and live events after subscription extend it`() = runTest {
// Новая семантика (v4, live-only outbox): snapshot — это «полное
// состояние на момент cursor», дальнейшие изменения приходят
// **только** live-flow'ом. Клиент сам мажет (snapshot ∪ live) и
// делает дедуп по id. Здесь проверяем, что:
// 1) chatSnapshot возвращает всё, что было на момент чтения;
// 2) live-events, подписанные ДО snapshot, идут ВСЕ (нет пропусков);
// 3) финальный snapshot содержит snapshot1 ∪ userId+assistantId.
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) }
// Подписываемся на durable-события диалога ДО повторного send'а.
val live = mutableListOf<ProtoEvent>()
val liveJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
agent.outbox.conversationEvents(conv.id).collect { live.add(it.event) }
}
// Берём snapshot уже после первой отправки, но ДО второй —
// новые сообщения едут потоком.
val snap1 = agent.chatSnapshot(conv.id)
assertTrue(snap1.messages.any { it is MessageRecord.UserMessage })
assertTrue(snap1.messages.any { it is MessageRecord.AssistantMessage })
fakeLlm.reply = "second reply"
conv.send(listOf(Content.Text("second user")))
delay(50)
job.cancel()
liveJob.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) {
// Live-flow получил ВСЕ durable-события обоих turn'ов.
// (snap1 не отрезает «до подписки», поэтому live начинает ловить
// и user1+assistant1, если они приходят позже подписки — но они
// уже в outbox'е до подписки и SharedFlow без replay их не выдаёт.
// Поэтому в live будут только события send'а#2, и это корректно.)
val liveIds = live.mapNotNull { e ->
when (e) {
is ProtoEvent.UserMessage -> e.id
is ProtoEvent.AssistantMessage -> e.id
else -> null
}
}.toSet()
assertEquals(final.messages.map { it.id }.toSet(), snapshotIds + deltaIds)
val snap2 = agent.chatSnapshot(conv.id)
val snap2Ids = snap2.messages.map { it.id }.toSet()
val snap1Ids = snap1.messages.map { it.id }.toSet()
// 1) live-events — подмножество финального состояния.
assertTrue(liveIds.all { it in snap2Ids }, "live event id missing from snap2: $liveIds vs $snap2Ids")
// 2) snap2 — строгое расширение snap1 (новый user/assistant).
assertTrue(snap2Ids.size > snap1Ids.size, "snap2 must extend snap1: $snap1Ids -> $snap2Ids")
// 3) новые id'ы в snap2 — ровно те, что приехали в live.
val newInSnap2 = snap2Ids - snap1Ids
assertEquals(liveIds, newInSnap2, "snap1 ∪ live must equal snap2: new=$newInSnap2 live=$liveIds")
}
@Test
fun `conversationsSnapshot carries cursor and a later chat arrives as an agent delta`() = runTest {
fun `conversationsSnapshot returns full list and live agentEvents carry new conversations`() = runTest {
// Новая семантика (v4): snapshot = полный список диалогов на момент
// чтения, новые диалоги приходят live-flow'ом agentEvents().
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) }
agent.outbox.agentEvents().collect { deltas.add(it.event) }
}
val snap = agent.conversationsSnapshot()
assertTrue(snap.conversations.any { it.id == conv1.id }, "snapshot=$snap")
val conv2 = agent.createConversation(temp = false)
delay(50)
job.cancel()
@@ -725,6 +741,11 @@ class ChatAgentTest {
deltas.any { it is AgentEvent.Created && it.conversationId == conv2.id },
"deltas=$deltas",
)
// Финальный snapshot содержит оба диалога.
val snap2 = agent.conversationsSnapshot()
assertTrue(snap2.conversations.any { it.id == conv1.id })
assertTrue(snap2.conversations.any { it.id == conv2.id })
}
}
@@ -4,6 +4,7 @@ import pw.binom.agentik.content.MessageOrigin
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.content.Content
import pw.binom.agentik.cursor.Cursor
import pw.binom.agentik.context.WorkingMemoryEntry
import kotlinx.coroutines.flow.toList
@@ -129,10 +130,10 @@ class PersistenceTest {
fun `message audit log - append and read back`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.messages.append(
MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0),
MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0, null),
)
stores.messages.append(
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0),
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0, null, null),
)
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
@@ -150,8 +151,8 @@ class PersistenceTest {
fun `message after timestamp filter`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0))
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1))
stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0, null))
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1, null))
val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10)
assertEquals(1, after.size)