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:
@@ -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)
|
||||
}
|
||||
|
||||
+23
-6
@@ -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 эмиссия онлайн-события — без блокировки продюсера и без хранения. */
|
||||
|
||||
+4
-5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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() },
|
||||
|
||||
+2
-4
@@ -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)
|
||||
|
||||
+17
-19
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-35
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user