Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acb4ee6186 | |||
| c0a933d251 | |||
| 29851c047a | |||
| c9995b263e | |||
| 7865eed836 | |||
| 0a7c40688c | |||
| 161be41adf | |||
| 68543357c2 | |||
| f946186ef5 | |||
| fb963bfb6b | |||
| 818f022ba3 | |||
| 0d1be42919 | |||
| 2d6cf89c52 | |||
| 8f85612665 | |||
| aef5083801 | |||
| 499db812ef | |||
| d6afc05c20 | |||
| acc7237e51 | |||
| bd65c29b48 | |||
| 2cc1d923b3 | |||
| 8f8f7f1020 | |||
| 3f260f3ac8 | |||
| d74af621d8 | |||
| 15f3952eba | |||
| a0b1209457 | |||
| a05e260457 | |||
| 7e66baf9e3 | |||
| 1134e32ea2 | |||
| e2f0e434d1 | |||
| 1f85cde1b8 | |||
| 8bb24dab3c | |||
| 7358175499 | |||
| 2d9ad526bb | |||
| dd7aec8df1 | |||
| bf2649a856 | |||
| 4ad59d5f5d |
@@ -5,6 +5,9 @@
|
|||||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||||
# release.yml для требуемых переменных.
|
# release.yml для требуемых переменных.
|
||||||
|
#
|
||||||
|
# :agentik-cli / :agentik-tui исключены из сборки (settings.gradle.kts
|
||||||
|
# 2026-09-17/21) — соответствующие шаги shadowJar тут НЕ запускаются.
|
||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -67,15 +70,10 @@ jobs:
|
|||||||
test -f standalone/build/libs/standalone-*-all.jar \
|
test -f standalone/build/libs/standalone-*-all.jar \
|
||||||
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||||
|
|
||||||
- name: Build :agentik-cli shadowJar
|
# Шага "Build :agentik-cli shadowJar" здесь нет: :agentik-cli исключён из
|
||||||
shell: bash
|
# settings.gradle.kts (2026-09-21). :agentik-tui — тоже исключён (2026-09-17).
|
||||||
run: |
|
# Когда/если оба вернутся, добавим отдельные шаги по аналогии с :standalone.
|
||||||
./gradlew :agentik-cli:shadowJar \
|
#
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
|
||||||
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
|
|
||||||
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
|
||||||
|
|
||||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ out/
|
|||||||
|
|
||||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
.cortexkit/
|
.cortexkit/
|
||||||
|
# opencode CLI local config (per-machine, не коммитим)
|
||||||
|
config.json
|
||||||
.veai/
|
.veai/
|
||||||
|
|
||||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
Status of message-log-api migration:
|
||||||
|
|
||||||
|
DONE:
|
||||||
|
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
|
||||||
|
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
|
||||||
|
- Content.kt (sealed: Text, Image)
|
||||||
|
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
|
||||||
|
- MessageStore.kt (interface, TokenStats, MessageEvent)
|
||||||
|
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
|
||||||
|
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
|
||||||
|
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
|
||||||
|
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
|
||||||
|
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
|
||||||
|
6. Wrote /tmp/run_rename.sh that runs the python script.
|
||||||
|
|
||||||
|
PENDING:
|
||||||
|
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
|
||||||
|
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
|
||||||
|
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
|
||||||
|
- Verify build compiles (run standalone tests).
|
||||||
|
|
||||||
|
Files that need import updates (per search):
|
||||||
|
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
|
||||||
|
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
|
||||||
|
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
|
||||||
|
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
|
||||||
|
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
|
||||||
|
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
|
||||||
|
- standalone/src/jvmMain/.../ToolDispatcher.kt
|
||||||
|
- standalone/src/jvmMain/.../ConversationLoop.kt
|
||||||
|
- standalone/src/jvmTest/.../ChatAgentTest.kt
|
||||||
|
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
|
||||||
|
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
|
||||||
|
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
|
||||||
|
|
||||||
|
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
|
||||||
|
Workaround needed before continuing the migration.
|
||||||
@@ -77,7 +77,7 @@ budget exhaustion, registry filter, parallel dispatch.
|
|||||||
## Чего здесь НЕТ
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||||
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
- Никакого persistent storage. Опирается на контракт `ContextStore`
|
||||||
(см. `:storage-core`).
|
(см. `:storage-core`).
|
||||||
|
|
||||||
## Текущий статус
|
## Текущий статус
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
|
api(project(":journal-api"))
|
||||||
api(project(":storage-core"))
|
api(project(":reflection-api"))
|
||||||
|
api(project(":context-api"))
|
||||||
|
|
||||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
|
|||||||
* Контекст, который тулсеты получают при активации.
|
* Контекст, который тулсеты получают при активации.
|
||||||
*
|
*
|
||||||
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
||||||
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
|
* добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||||
* могли читать/писать сообщения и память.
|
* могли читать/писать сообщения и память.
|
||||||
*
|
*
|
||||||
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package pw.binom.agentik.cli
|
|||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import pw.binom.agentik.client.agentikHttpClient
|
import pw.binom.agentik.client.applyAgentikDefaults
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-клиент CLI: движок CIO + конфигурация agentik.
|
* HTTP-клиент CLI: движок CIO + конфигурация agentik.
|
||||||
*
|
*
|
||||||
* Движок живёт здесь, а не в `:client`: библиотека не выбирает транспорт за
|
* Движок выбирается здесь, а не в `:client`: библиотека не выбирает транспорт за
|
||||||
* потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64)
|
* потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64)
|
||||||
* покрываются CIO.
|
* покрываются CIO.
|
||||||
*
|
*
|
||||||
@@ -18,6 +18,7 @@ import pw.binom.agentik.client.agentikHttpClient
|
|||||||
* [token] = `null` — авторизация выключена.
|
* [token] = `null` — авторизация выключена.
|
||||||
*/
|
*/
|
||||||
internal fun defaultCliHttpClient(token: String? = null): HttpClient =
|
internal fun defaultCliHttpClient(token: String? = null): HttpClient =
|
||||||
agentikHttpClient(engineFactory = CIO, token = token) {
|
HttpClient(CIO) {
|
||||||
|
applyAgentikDefaults(token)
|
||||||
engine { requestTimeout = 0 }
|
engine { requestTimeout = 0 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import kotlinx.coroutines.launch
|
|||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
import pw.binom.agentik.cli.defaultCliHttpClient
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||||
@@ -27,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
// Подписываемся на поток событий ДО send: события, отправленные
|
// Подписываемся на поток событий ДО send: события, отправленные
|
||||||
// до подписки, не реплеятся (shared-flow без replay).
|
// до подписки, не реплеятся (shared-flow без replay).
|
||||||
val eventsJob = launch {
|
val eventsJob = launch {
|
||||||
conv.events(Instant.DISTANT_PAST)
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||||
// onEach печатает и терминальный event, takeWhile лишь
|
// onEach печатает и терминальный event, takeWhile лишь
|
||||||
// завершает сбор после него.
|
// завершает сбор после него.
|
||||||
|
.map { it.event }
|
||||||
.onEach { ev -> emit(ev) }
|
.onEach { ev -> emit(ev) }
|
||||||
.takeWhile { ev -> !isTerminal(ev) }
|
.takeWhile { ev -> !isTerminal(ev) }
|
||||||
.collect { }
|
.collect { }
|
||||||
@@ -53,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||||
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||||
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||||
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
|
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
|
||||||
is Event.End -> println("event End")
|
is Event.End -> println("event End")
|
||||||
is Event.Interrupted -> println("event Interrupted")
|
is Event.Interrupted -> println("event Interrupted")
|
||||||
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.launch
|
|||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -46,7 +46,11 @@ internal class TuiBackend(
|
|||||||
state.postSystem("подключено к ${state.config.server}")
|
state.postSystem("подключено к ${state.config.server}")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
||||||
|
// распаковываем .event для получения AgentEvent (раньше был
|
||||||
|
// отдельный метод agent.events(), теперь упразднён — события
|
||||||
|
// живут в outbox-сущности).
|
||||||
|
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||||
} catch (_: kotlinx.coroutines.CancellationException) {
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
// штатная отмена при закрытии UI
|
// штатная отмена при закрытии UI
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -88,12 +92,12 @@ internal class TuiBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подписывается на [Conversation.events] и перенаправляет их в [state].
|
* Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
|
||||||
*/
|
*/
|
||||||
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||||
eventsJob?.cancel()
|
eventsJob?.cancel()
|
||||||
eventsJob = scope.launch {
|
eventsJob = scope.launch {
|
||||||
conv.events(from).collect { ev -> dispatch(ev) }
|
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package pw.binom.agentik.tui
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -25,6 +26,16 @@ internal class FakeAgent(
|
|||||||
private set
|
private set
|
||||||
val conversations = mutableListOf<FakeConversation>()
|
val conversations = mutableListOf<FakeConversation>()
|
||||||
|
|
||||||
|
// Storage handles не используются тестами TuiBackend — тесты проверяют
|
||||||
|
// маршрутизацию Conversation.events в UI state. Outbox stub-ы возвращают
|
||||||
|
// emptyFlow, journal — error-on-access (никто не должен его трогать).
|
||||||
|
override val journal: JournalStore = error("journal not used in TuiBackend tests")
|
||||||
|
override val outbox: OutboxStore = object : OutboxStore {
|
||||||
|
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
|
||||||
|
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
override fun createConversation(temp: Boolean): Conversation {
|
override fun createConversation(temp: Boolean): Conversation {
|
||||||
createCount++
|
createCount++
|
||||||
val c = conversationFactory()
|
val c = conversationFactory()
|
||||||
@@ -40,8 +51,6 @@ internal class FakeAgent(
|
|||||||
|
|
||||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
||||||
conversations.toList()
|
conversations.toList()
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
@@ -170,7 +170,7 @@ class TuiBackendTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
val now = kotlin.time.Clock.System.now()
|
val now = kotlin.time.Clock.System.now()
|
||||||
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
||||||
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
|
conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||||
|
|||||||
+364
-63
@@ -1,101 +1,402 @@
|
|||||||
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
|
# `:client` — Ktor-клиент к `:server` (KMP, jvm + native)
|
||||||
|
|
||||||
## Что это
|
Тонкий HTTP-клиент к `:server`-фасаду + локальные примитивы, чтобы
|
||||||
|
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
|
||||||
|
про HTTP, JSON, SSE и lifecycle `Conversation`.
|
||||||
|
|
||||||
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
|
## Что есть
|
||||||
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
|
|
||||||
интерфейсы `:proto`:
|
|
||||||
|
|
||||||
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
|
- `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
|
||||||
- `AgentClient` — список и lifecycle диалогов.
|
`Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
|
||||||
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
|
||||||
`getMessages()`, `rename()`, `close()`.
|
- `Agent`: `createConversation` / `getConversation` / `getConversations` /
|
||||||
- Внутренний парсер SSE → `Flow<Event>`.
|
`deleteConversation` / `journal` / `outbox` / `close`.
|
||||||
|
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
|
||||||
|
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
|
||||||
|
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
|
||||||
|
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
|
||||||
|
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
|
||||||
|
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
|
||||||
|
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
|
||||||
|
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
|
||||||
|
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
|
||||||
|
с основным потоком событий. См. ниже.
|
||||||
|
|
||||||
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
|
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||||
с теми же типами, что и сервер. Один и тот же клиент работает на
|
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
|
||||||
JVM, iOS, macOS, Linux, Windows.
|
|
||||||
|
|
||||||
## Где используется
|
## Подключение
|
||||||
|
|
||||||
- `:agentik-cli` — REPL.
|
|
||||||
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
|
||||||
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
|
||||||
|
|
||||||
## Как подключить
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// build.gradle.kts
|
// build.gradle.kts
|
||||||
kotlin {
|
dependencies {
|
||||||
sourceSets.commonMain.dependencies {
|
api("pw.binom.agentik:client:0.1.0")
|
||||||
api("pw.binom.agentik:client:0.1.0")
|
// Движок — на твой выбор (один из):
|
||||||
}
|
implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native
|
||||||
}
|
implementation("io.ktor:ktor-client-okhttp:3.x") // JVM
|
||||||
|
implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS
|
||||||
// ваш код:
|
// Опционально — только если будешь использовать `InMemoryJournalStore`
|
||||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
|
||||||
val conv = agent.createConversation(title = "test")
|
api("pw.binom.agentik:journal-inmemory:0.1.0")
|
||||||
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
|
||||||
when (event) {
|
|
||||||
is Event.AppendText -> print(event.body)
|
|
||||||
is Event.End -> println("\n--- end ---")
|
|
||||||
is Event.Error -> error("agent error: ${event.message}")
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Версии
|
## Что клиент хранит локально (persistence)
|
||||||
|
|
||||||
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
Либа **не** имеет `SettingsRepository` / `Config` — это намеренно: UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain, Android DataStore, NSUserDefaults, ...). Либа не навязывает формат, но клиент должен сериализовать у себя минимум:
|
||||||
|
|
||||||
Поддерживает все KMP-таргеты, что и `:proto`.
|
| Поле | Что это | Где взять |
|
||||||
|
|---|---|---|
|
||||||
|
| `clientId` (параметр `id` в `AgentikAgent`) | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id — это **произвольная строка клиента**, обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется один раз при первом запуске (`UUID.randomUUID().toString()`) и сохраняется. Никогда не меняется. |
|
||||||
|
| `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
|
||||||
|
| `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
|
||||||
|
|
||||||
## Примеры API
|
Опционально (для UX): `engineFactory` — обычно compile-time выбор по платформе (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS).
|
||||||
|
|
||||||
|
Минимальный JSON для UI, который хранит в файле:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"baseUrl": "https://agent.example.com/agentik",
|
||||||
|
"token": "s3cret"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере.
|
||||||
|
|
||||||
|
## Быстрый старт: свой клиент за 5 минут
|
||||||
|
|
||||||
|
Один self-contained пример: создаём агента, открываем диалог,
|
||||||
|
отправляем сообщение, печатаем streaming-ответ.
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// список диалогов
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
agent.getConversations().collect { println(it.id to it.title) }
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
// live-подписка на события отдельного диалога
|
fun main() = runBlocking {
|
||||||
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
|
||||||
|
val agent = AgentikAgent(
|
||||||
|
id = "my-client",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
token = "s3cret", // или null, если не нужен
|
||||||
|
)
|
||||||
|
|
||||||
// прерывание текущего хода
|
// 2. Открыть диалог, отправить сообщение.
|
||||||
conversation.interrupt()
|
val conv = agent.createConversation(temp = false)
|
||||||
|
conv.send(listOf(Content.Text("Привет")))
|
||||||
|
|
||||||
// история
|
// 3. Собирать streaming-ответ.
|
||||||
conversation.getMessages(offset = 0).collect { msg ->
|
conv.events(after = Clock.System.now()).collect { ev ->
|
||||||
when (msg) {
|
when (ev) {
|
||||||
is Message.UserMessage -> println("user: ${msg.content}")
|
is Event.StartResponse -> println("[start]")
|
||||||
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
is Event.AppendText -> print(ev.body)
|
||||||
else -> Unit
|
is Event.End -> println("[end]")
|
||||||
|
is Event.Error -> println("[error: ${ev.message}]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Чистый shutdown.
|
||||||
|
conv.close()
|
||||||
|
agent.close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||||
|
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
|
||||||
|
|
||||||
|
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
||||||
|
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||||
|
|
||||||
|
### Добавить локальный кэш истории (ещё 4 строки)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
|
||||||
|
val cache = InMemoryJournalStore()
|
||||||
|
|
||||||
|
// Backfill + live-refresh в одном фоне:
|
||||||
|
launch {
|
||||||
|
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
|
||||||
|
.collect { cache.append(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// История — теперь из кэша, без HTTP:
|
||||||
|
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
history.forEach { rec ->
|
||||||
|
when (rec) {
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
|
||||||
|
is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Шаблон "remote.listFlow → local.append" работает с любым
|
||||||
|
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
|
||||||
|
"без геморроя".
|
||||||
|
|
||||||
|
### Что вообще не нужно писать самому
|
||||||
|
|
||||||
|
- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует
|
||||||
|
`agentikJson` и `InstantSerializer`.
|
||||||
|
- SSE-парсер — `readSse()` внутри `:client`.
|
||||||
|
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
|
||||||
|
`JournalStore.listFlow` сама пагинирует.
|
||||||
|
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
|
||||||
|
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
|
||||||
|
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
|
||||||
|
- Движковые настройки (requestTimeout и пр.) — `HttpClient(engineFactory) { ... }`
|
||||||
|
создаётся здесь; для нестандартных движковых настроек используй
|
||||||
|
`agentikHttpClient(engineFactory, token)` напрямую (он экспортирован).
|
||||||
|
|
||||||
|
### Что нужно написать самому
|
||||||
|
|
||||||
|
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
|
||||||
|
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
|
||||||
|
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
|
||||||
|
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
|
||||||
|
|
||||||
|
|
||||||
|
## Базовый пример: send + collect events
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
|
||||||
|
val agent = AgentikAgent(
|
||||||
|
id = "agentik",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
)
|
||||||
|
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||||
|
|
||||||
|
conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is Event.AppendText -> print(ev.body) // streaming чанки
|
||||||
|
is Event.End -> println("\n--- end ---")
|
||||||
|
is Event.Error -> error("agent error: ${ev.message}")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## История с локальным кэшем
|
||||||
|
|
||||||
|
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
|
||||||
|
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
|
||||||
|
чтение истории — из локального кэша.
|
||||||
|
|
||||||
|
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
|
||||||
|
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
|
||||||
|
реализовывал интерфейс.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ChatSession(
|
||||||
|
private val agent: pw.binom.agentik.proto.Agent,
|
||||||
|
val conversationId: String,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
|
||||||
|
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
|
||||||
|
// (модуль `:journal-api`).
|
||||||
|
val cache = InMemoryJournalStore()
|
||||||
|
|
||||||
|
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
|
||||||
|
private val scope = kotlinx.coroutines.CoroutineScope(
|
||||||
|
kotlinx.coroutines.SupervisorJob() +
|
||||||
|
kotlinx.coroutines.Dispatchers.Default,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// 1. Backfill: забираем всю историю разговора с сервера.
|
||||||
|
scope.launch {
|
||||||
|
agent.journal.listFlow(
|
||||||
|
conversationId = conversationId,
|
||||||
|
after = Instant.DISTANT_PAST,
|
||||||
|
).collect { cache.append(it) }
|
||||||
|
}
|
||||||
|
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
||||||
|
scope.launch {
|
||||||
|
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||||
|
if (ev is Event.End) {
|
||||||
|
val newest = cache.let {
|
||||||
|
// last-seen курсор — последний createdAt в кэше
|
||||||
|
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||||
|
?: Instant.DISTANT_PAST
|
||||||
|
}
|
||||||
|
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
|
||||||
|
.forEach { cache.append(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun history() = kotlinx.coroutines.runBlocking {
|
||||||
|
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Использование:
|
||||||
|
val session = ChatSession(agent, conv.id)
|
||||||
|
|
||||||
|
// История — из кэша:
|
||||||
|
session.history().forEach { rec ->
|
||||||
|
when (rec) {
|
||||||
|
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
|
||||||
|
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
|
||||||
|
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
|
||||||
|
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
|
||||||
|
is MessageRecord.Error -> println("error: ${rec.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправить новое сообщение:
|
||||||
|
session.scope.launch {
|
||||||
|
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
|
||||||
|
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
|
||||||
|
`rec is MessageRecord.UserMessage` для реплик пользователя,
|
||||||
|
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
|
||||||
|
|
||||||
|
## Стриминг live-ответа
|
||||||
|
|
||||||
|
Для streaming-рендера текущего хода подписывайся на `events()` и
|
||||||
|
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
|
||||||
|
только для UI-feedback во время хода. После `End` хода запись уже
|
||||||
|
появится в кэше через refresh-блок выше.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
|
||||||
|
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is Event.StartResponse -> println("[start]")
|
||||||
|
is Event.AppendText -> print(ev.body)
|
||||||
|
is Event.AppendImage -> showImage(ev.body)
|
||||||
|
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
|
||||||
|
is Event.ToolResult -> println("[result]")
|
||||||
|
is Event.End -> println("[end]")
|
||||||
|
is Event.Error -> println("[error: ${ev.message}]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Прерывание хода
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
agent.getConversation(convId)!!.interrupt()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-conversation
|
||||||
|
|
||||||
|
Один `Agent`, много `ChatSession`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val sessions = mutableMapOf<String, ChatSession>()
|
||||||
|
|
||||||
|
fun open(convId: String): ChatSession =
|
||||||
|
sessions.getOrPut(convId) { ChatSession(agent, convId) }
|
||||||
|
|
||||||
|
fun close(convId: String) {
|
||||||
|
sessions.remove(convId)?.close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
|
||||||
|
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
|
||||||
|
|
||||||
|
## Где `:client` НЕ помогает
|
||||||
|
|
||||||
|
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
|
||||||
|
отдаёт типы и потоки.
|
||||||
|
- **Персистентность кэша** — `InMemoryJournalStore` хранит в RAM. Для
|
||||||
|
диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в
|
||||||
|
`:journal-ksqlite` как образец).
|
||||||
|
- **Нестандартные движковые настройки** — для `requestTimeout`,
|
||||||
|
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
|
||||||
|
напрямую.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```
|
```
|
||||||
./gradlew :client:jvmTest
|
./gradlew :client:jvmTest
|
||||||
```
|
```
|
||||||
|
|
||||||
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||||
401/404.
|
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
|
||||||
|
reconnect / exhausted attempts → Failed / close → cancel).
|
||||||
|
|
||||||
## Чего здесь НЕТ
|
## Auto-reconnect для живого outbox
|
||||||
|
|
||||||
- Никакого LLM-кода. Это просто клиент.
|
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
|
||||||
- Никакого persistent state. История хранится у сервера, клиент её
|
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
|
||||||
запрашивает через `getMessages` или подписывается через `events`.
|
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
|
||||||
|
|
||||||
## Текущий статус
|
```kotlin
|
||||||
|
val recon = ReconnectingOutbox(
|
||||||
|
outbox = agent.outbox, // или HttpEventStore
|
||||||
|
scope = myScreenScope,
|
||||||
|
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
|
||||||
|
)
|
||||||
|
|
||||||
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
|
||||||
но клиент совместим с любым сервером, который держит wire-контракт
|
scope.launch {
|
||||||
`:server`.
|
recon.connectionStatus().collect { status ->
|
||||||
|
when (status) {
|
||||||
|
is Connecting -> ui.showBanner("connecting...")
|
||||||
|
is Connected -> ui.hideBanner()
|
||||||
|
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
|
||||||
|
is Failed -> ui.showError(status.cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// На выходе (например, navigation back):
|
||||||
|
recon.close() // отменяет background-loop, потоки терминируются
|
||||||
|
```
|
||||||
|
|
||||||
|
Два потока **независимы** — `events()` содержит только `CommonEvent`,
|
||||||
|
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
|
||||||
|
"мешающего" `Connecting`/`Disconnected` в потоке событий.
|
||||||
|
|
||||||
|
Параметры backoff (см. `BackoffPolicy`):
|
||||||
|
- `initial` / `max` — границы задержки
|
||||||
|
- `multiplier` — множитель на каждом шаге
|
||||||
|
- `jitter` — рандом-разброс (по умолчанию 20%)
|
||||||
|
- `maxAttempts` — лимит попыток; после — `Failed` + закрытие потока
|
||||||
|
|
||||||
|
Если нужен фиксированный delay для тестов — `BackoffPolicy.Fixed(10.milliseconds, attempts = 3)`.
|
||||||
|
|
||||||
## Известное ограничение
|
## Известное ограничение
|
||||||
|
|
||||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||||
terminal (TTY). Это upstream-особенность Ktor SSE.
|
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":proto"))
|
api(project(":proto"))
|
||||||
|
api(project(":outbox-api"))
|
||||||
|
api(project(":journal-api"))
|
||||||
|
|
||||||
api(libs.ktor.client.core)
|
api(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
|||||||
@@ -4,39 +4,38 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.prepareGet
|
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
import io.ktor.client.statement.HttpResponse
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||||
*
|
*
|
||||||
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
|
* HttpClient создаётся внутри из переданного engine и закрывается в [close].
|
||||||
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
|
*
|
||||||
* POST `/conversations`. Используем `runBlocking` — это одноразовая
|
* **Storage handles** ([journal], [outbox]) — read-only views на серверные
|
||||||
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
|
* хранилища.
|
||||||
* решает, что делать.
|
|
||||||
*/
|
*/
|
||||||
internal class AgentClient(
|
internal class AgentClient(
|
||||||
private val httpClient: HttpClient,
|
|
||||||
private val baseUrl: String,
|
|
||||||
override val id: String,
|
override val id: String,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: HttpClient,
|
||||||
) : Agent {
|
) : Agent {
|
||||||
|
|
||||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
|
||||||
override fun createConversation(temp: Boolean): Conversation =
|
override fun createConversation(temp: Boolean): Conversation =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
||||||
@@ -54,7 +53,7 @@ internal class AgentClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteConversation(id: String): Boolean {
|
override suspend fun deleteConversation(id: String): Boolean {
|
||||||
val response = httpClient.delete("$agentUrl/conversations/$id")
|
val response: HttpResponse = httpClient.delete("$agentUrl/conversations/$id")
|
||||||
return response.status == HttpStatusCode.NoContent
|
return response.status == HttpStatusCode.NoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +65,7 @@ internal class AgentClient(
|
|||||||
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
override fun close() {
|
||||||
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
httpClient.close()
|
||||||
.execute { response ->
|
|
||||||
check(response.status == HttpStatusCode.OK) {
|
|
||||||
"events: server returned ${response.status}"
|
|
||||||
}
|
|
||||||
readSse(response.bodyAsChannel())
|
|
||||||
.collect { payload ->
|
|
||||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,74 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
|
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||||
* (модуль `:server`).
|
*
|
||||||
|
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
|
||||||
|
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
|
||||||
|
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
|
||||||
|
* снаружи — всё под капотом.
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* val http = HttpClient(CIO) { applyAgentikDefaults(token = "s3cret") }
|
* val agent = AgentikAgent(
|
||||||
* val client = AgentikAgent(
|
* id = "my-client",
|
||||||
* id = "my-agent",
|
|
||||||
* baseUrl = "http://localhost:8080/agentik",
|
* baseUrl = "http://localhost:8080/agentik",
|
||||||
* httpClient = http,
|
* engineFactory = CIO,
|
||||||
|
* token = "s3cret",
|
||||||
* )
|
* )
|
||||||
* val conv = client.createConversation(temp = false)
|
* val conv = agent.createConversation(temp = false)
|
||||||
* conv.send(listOf(Content.Text("hi")))
|
* conv.send(listOf(Content.Text("hi")))
|
||||||
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
|
* agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||||
|
* .map { it.event }
|
||||||
|
* .collect { ... }
|
||||||
|
* agent.close() // закрывает HttpClient
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
|
* ## Что клиент должен хранить локально (persistence)
|
||||||
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
|
||||||
* конфига).
|
|
||||||
*
|
*
|
||||||
* Клиент приходит снаружи: `:client` не выбирает движок. Собрать [HttpClient]
|
* Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
|
||||||
* можно через [agentikHttpClient] (фабрика движка + опциональные движковые
|
* UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain,
|
||||||
* настройки) или вручную, применив к блоку конфигурации [applyAgentikDefaults]
|
* `SharedPreferences`, Android DataStore, NSUserDefaults, ...). Либа
|
||||||
* (JSON + опциональный Bearer-токен).
|
* не навязывает формат, но вот минимальный набор, который клиент должен
|
||||||
|
* сериализовать у себя, чтобы пережить перезапуск:
|
||||||
|
*
|
||||||
|
* | Поле | Что это | Где взять |
|
||||||
|
* |---|---|---|
|
||||||
|
* | `id` | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id, а произвольная строка клиента — обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется клиентом при первом запуске, сохраняется локально |
|
||||||
|
* | `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
|
||||||
|
* | `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
|
||||||
|
*
|
||||||
|
* Опционально (для UX):
|
||||||
|
* | Поле | Зачем |
|
||||||
|
* |---|---|
|
||||||
|
* | `engineFactory` | Зависит от платформы (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS). Выбор — обычно compile-time. |
|
||||||
|
*
|
||||||
|
* Пример минимального persistence-файла (для UI, который хранит JSON):
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "baseUrl": "https://agent.example.com/agentik",
|
||||||
|
* "token": "s3cret"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `clientId` генерируется один раз при первой установке (`UUID.randomUUID().toString()`)
|
||||||
|
* и больше не меняется — иначе сломается log multiplexing на сервере.
|
||||||
|
*
|
||||||
|
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
||||||
|
* HttpClient (идемпотентно). После этого `createConversation` /
|
||||||
|
* `getConversation` etc. не определены.
|
||||||
*/
|
*/
|
||||||
fun AgentikAgent(
|
fun AgentikAgent(
|
||||||
id: String,
|
id: String,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
httpClient: HttpClient,
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
token: String? = null,
|
||||||
|
): Agent = AgentClient(
|
||||||
|
id = id,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
httpClient = agentikHttpClient(engineFactory = engineFactory, token = token),
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,18 +6,12 @@ import io.ktor.client.request.get
|
|||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.request.patch
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.prepareGet
|
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -72,22 +66,6 @@ internal class ConversationClient(
|
|||||||
httpClient.post("$convUrl/interrupt")
|
httpClient.post("$convUrl/interrupt")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<Event> = flow {
|
|
||||||
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
|
|
||||||
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
|
|
||||||
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
|
|
||||||
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
|
|
||||||
.execute { response ->
|
|
||||||
check(response.status == HttpStatusCode.OK) {
|
|
||||||
"events: server returned ${response.status}"
|
|
||||||
}
|
|
||||||
readSse(response.bodyAsChannel())
|
|
||||||
.collect { payload ->
|
|
||||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||||
httpClient.get("$convUrl/messages") {
|
httpClient.get("$convUrl/messages") {
|
||||||
parameter("after", after.toString())
|
parameter("after", after.toString())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.HttpClientConfig
|
|
||||||
import io.ktor.client.engine.HttpClientEngineConfig
|
|
||||||
import io.ktor.client.engine.HttpClientEngineFactory
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
import io.ktor.client.plugins.DefaultRequest
|
import io.ktor.client.plugins.DefaultRequest
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
@@ -11,26 +9,20 @@ import io.ktor.http.HttpHeaders
|
|||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Общая конфигурация HTTP-клиента agentik — платформо-независимая часть.
|
* Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
|
||||||
*
|
*
|
||||||
* `:client` НЕ выбирает движок: его приносит потребитель. Здесь живёт только то,
|
* Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
|
||||||
* без чего клиент несовместим с `/agentik`:
|
* не нужен — он передаёт engine в [AgentikAgent] и получает готовый
|
||||||
* - JSON-конфиг [agentikJson] (обязан совпадать с серверным);
|
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
|
||||||
* - при заданном [token] — `Authorization: Bearer <token>` на ВСЕ запросы
|
* на [pw.binom.agentik.proto.Agent.close].
|
||||||
* через [DefaultRequest] (накрывает 10 REST-вызовов и оба SSE-потока;
|
|
||||||
* заголовок живёт на клиенте, а не в отдельных запросах).
|
|
||||||
*
|
*
|
||||||
* `null` — авторизация выключена, заголовок не отправляется.
|
* Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
|
||||||
*
|
* (например, дополнительные нестандартные запросы в обход `Agent` API).
|
||||||
* Потребитель, знающий свой движок, добавляет к этому движковые настройки, напр.:
|
|
||||||
* ```
|
|
||||||
* val http = HttpClient(CIO) {
|
|
||||||
* engine { requestTimeout = 0 } // CIO-специфика, живёт у потребителя
|
|
||||||
* applyAgentikDefaults(token)
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
fun HttpClientConfig<*>.applyAgentikDefaults(token: String? = null) {
|
fun agentikHttpClient(
|
||||||
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
|
token: String? = null,
|
||||||
|
): HttpClient = HttpClient(engineFactory) {
|
||||||
install(ContentNegotiation) { json(agentikJson) }
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
install(DefaultRequest) {
|
install(DefaultRequest) {
|
||||||
@@ -38,23 +30,3 @@ fun HttpClientConfig<*>.applyAgentikDefaults(token: String? = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Создаёт [HttpClient] из фабрики движка потребителя и сразу применяет к нему
|
|
||||||
* конфигурацию agentik ([applyAgentikDefaults]).
|
|
||||||
*
|
|
||||||
* Это точка, где `:client` НЕ привязан к реализации транспорта: [engineFactory]
|
|
||||||
* выбирает потребитель (CIO, OkHttp, Darwin, …), а `:client` только конфигурирует
|
|
||||||
* созданный клиент.
|
|
||||||
*
|
|
||||||
* [configure] — опциональный последний штрих потребителя (движковые настройки:
|
|
||||||
* таймауты, прокси, логирование). Вызывается ПОСЛЕ [applyAgentikDefaults].
|
|
||||||
*/
|
|
||||||
fun <T : HttpClientEngineConfig> agentikHttpClient(
|
|
||||||
engineFactory: HttpClientEngineFactory<T>,
|
|
||||||
token: String? = null,
|
|
||||||
configure: (HttpClientConfig<T>.() -> Unit)? = null,
|
|
||||||
): HttpClient = HttpClient(engineFactory) {
|
|
||||||
applyAgentikDefaults(token)
|
|
||||||
configure?.invoke(this)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]):
|
||||||
|
* - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток
|
||||||
|
* [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes])
|
||||||
|
* - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут:
|
||||||
|
* сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает
|
||||||
|
* `.event` для обратной совместимости с форматом AgentEvent)
|
||||||
|
* - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events`
|
||||||
|
*
|
||||||
|
* Для [conversationEvents] с `conversationId == null` (события всех диалогов)
|
||||||
|
* fallback на default [OutboxStore.conversationEvents] — общий поток
|
||||||
|
* `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и
|
||||||
|
* оптимизировать его отдельно нерационально.
|
||||||
|
*
|
||||||
|
* [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()`
|
||||||
|
* (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть
|
||||||
|
* контрактное значение). Клиент, который полагался на gap detection через
|
||||||
|
* message store, продолжит работать — просто fallback никогда не сработает.
|
||||||
|
*
|
||||||
|
* **Импорты [CommonEvent]/[AgentEvent]/[Event] идут напрямую из
|
||||||
|
* `pw.binom.agentik.outbox`** — typealias'ы в `:proto.CommonEvent` и т.п.
|
||||||
|
* НЕ поддерживают nested-class access (`CommonEvent.Agent` через alias
|
||||||
|
* даёт "Unresolved qualified name"), поэтому приходится использовать
|
||||||
|
* конкретный пакет. Типы идентичны, alias только для удобства внешнего API.
|
||||||
|
*/
|
||||||
|
internal class HttpEventStore(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : OutboxStore {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/outbox/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"events: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(CommonEvent.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события.
|
||||||
|
* Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`.
|
||||||
|
*/
|
||||||
|
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"agentEvents: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
val event = agentikJson.decodeFromString(AgentEvent.serializer(), payload)
|
||||||
|
emit(CommonEvent.Agent(date = event.date, event = event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override с `conversationId != null` — идём в `/conversations/{id}/events`.
|
||||||
|
* С `null` (события всех диалогов) — fallback на default impl из [EventStore]:
|
||||||
|
* общий `/events/all` + filter.
|
||||||
|
*/
|
||||||
|
override fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String?,
|
||||||
|
): Flow<CommonEvent.Conversation> {
|
||||||
|
if (conversationId == null) {
|
||||||
|
return super.conversationEvents(after, null)
|
||||||
|
}
|
||||||
|
return flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/conversations/$conversationId/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"conversationEvents: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
val event = agentikJson.decodeFromString(Event.serializer(), payload)
|
||||||
|
emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* У HTTP-варианта нет своего endpoint'а для earliest-event-date.
|
||||||
|
* Контракт [EventStore.earliestEventDate] для пустого буфера говорит
|
||||||
|
* "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст"
|
||||||
|
* (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`.
|
||||||
|
*/
|
||||||
|
override suspend fun earliestEventDate(): Instant = Clock.System.now()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoint**: `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
|
||||||
|
* (см. [pw.binom.agentik.server.journalRoutes]).
|
||||||
|
*
|
||||||
|
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
|
||||||
|
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
|
||||||
|
* в `:server`'s proto-роутах (который отдаёт project'нутые
|
||||||
|
* [pw.binom.agentik.proto.Message]), здесь клиент получает полный transcript
|
||||||
|
* с tool-call/tool-result/error payload'ами, turn-tokens и context'ом.
|
||||||
|
*
|
||||||
|
* **listFlow** — default cold-flow paging через [list] (N+1 round-trip,
|
||||||
|
* дефолтная реализация из [JournalStore]). Для remote/SQL-backed store'а
|
||||||
|
* это OK: server-side paging + client-side flow compose'ится естественно.
|
||||||
|
*
|
||||||
|
* **Read-only**: [JournalStore] не имеет `append` — запись только через
|
||||||
|
* writer-референс, который ChatAgent держит внутри (тип
|
||||||
|
* `MutableJournalStore`, не выставлен наружу через [pw.binom.agentik.proto.Agent]).
|
||||||
|
*/
|
||||||
|
internal class HttpJournalStore(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : JournalStore {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> {
|
||||||
|
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/messages") {
|
||||||
|
parameter("after", after.toString())
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"journal.list: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<List<MessageRecord>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
|
import kotlin.concurrent.atomics.AtomicReference
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние подключения к удалённому [OutboxStore]. Эмитится через
|
||||||
|
* [ReconnectingOutbox.connectionStatus] — отдельным потоком, **не**
|
||||||
|
* смешивается с [ReconnectingOutbox.events].
|
||||||
|
*
|
||||||
|
* Типичный цикл:
|
||||||
|
* ```
|
||||||
|
* Connecting(1) → Connected → ... → Disconnected(reason, retryIn) →
|
||||||
|
* Connecting(2) → Connected → ...
|
||||||
|
* ```
|
||||||
|
* При полном исчерпании попыток ([BackoffPolicy.maxAttempts]) —
|
||||||
|
* финальный [Failed].
|
||||||
|
*/
|
||||||
|
sealed interface ConnectionStatus {
|
||||||
|
|
||||||
|
/** Начата попытка подключения (включая первую — `attempt == 1`). */
|
||||||
|
data class Connecting(val attempt: Int) : ConnectionStatus
|
||||||
|
|
||||||
|
/** Получен первый event с сервера после [Connecting] / [Disconnected]. */
|
||||||
|
data class Connected(val since: Instant) : ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стрим оборвался (network error, server close, таймаут). [reason] —
|
||||||
|
* причина, `null` если штатное завершение. [willRetryIn] — через сколько
|
||||||
|
* будет следующая попытка (`null` если [Failed]).
|
||||||
|
*/
|
||||||
|
data class Disconnected(
|
||||||
|
val reason: Throwable?,
|
||||||
|
val willRetryIn: Duration?,
|
||||||
|
) : ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все попытки исчерпаны ([BackoffPolicy.maxAttempts]). Поток [events]
|
||||||
|
* закрывается после этого. Создатель [ReconnectingOutbox] должен
|
||||||
|
* решить, что делать — показать ошибку пользователю, пересоздать
|
||||||
|
* outbox и т.п.
|
||||||
|
*/
|
||||||
|
data class Failed(val cause: Throwable) : ConnectionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Политика backoff для [ReconnectingOutbox]. Параметры:
|
||||||
|
*
|
||||||
|
* - [initial] — задержка перед первой retry-попыткой.
|
||||||
|
* - [max] — потолок задержки (после серии умножений).
|
||||||
|
* - [multiplier] — множитель на каждом шаге (например, `2.0` → 1s, 2s, 4s, 8s, ...).
|
||||||
|
* - [jitter] — доля случайного разброса `[0, jitter]` от текущей задержки
|
||||||
|
* (например, `0.2` = ±20%). Снижает thundering-herd при массовом reconnect.
|
||||||
|
* - [maxAttempts] — лимит попыток. `Int.MAX_VALUE` = бесконечно.
|
||||||
|
*/
|
||||||
|
data class BackoffPolicy(
|
||||||
|
val initial: Duration = 1.seconds,
|
||||||
|
val max: Duration = 30.seconds,
|
||||||
|
val multiplier: Double = 2.0,
|
||||||
|
val maxAttempts: Int = Int.MAX_VALUE,
|
||||||
|
val jitter: Double = 0.2,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(initial > Duration.ZERO) { "initial must be positive" }
|
||||||
|
require(max >= initial) { "max must be >= initial" }
|
||||||
|
require(multiplier >= 1.0) { "multiplier must be >= 1.0" }
|
||||||
|
require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
|
||||||
|
require(jitter in 0.0..1.0) { "jitter must be in [0, 1]" }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** 1s → 2s → 4s → ... → 30s, jitter ±20%, бесконечные попытки. */
|
||||||
|
val Default: BackoffPolicy = BackoffPolicy()
|
||||||
|
|
||||||
|
/** Только для тестов: фиксированные задержки без разброса. */
|
||||||
|
fun Fixed(delay: Duration, attempts: Int = 3): BackoffPolicy =
|
||||||
|
BackoffPolicy(
|
||||||
|
initial = delay,
|
||||||
|
max = delay,
|
||||||
|
multiplier = 1.0,
|
||||||
|
maxAttempts = attempts,
|
||||||
|
jitter = 0.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над [OutboxStore] с автоматическим reconnect при обрыве стрима.
|
||||||
|
*
|
||||||
|
* **Два независимых потока**:
|
||||||
|
* - [events] — `Flow<CommonEvent>`, тот же контракт что [OutboxStore.events],
|
||||||
|
* но с автоматическим переподключением через [BackoffPolicy]. Cursor
|
||||||
|
* (`lastSeen`) сохраняется между попытками — клиент не теряет события.
|
||||||
|
* - [connectionStatus] — `Flow<ConnectionStatus>`, **параллельный** поток
|
||||||
|
* lifecycle подключения. Не смешивается с [events].
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val outbox = ReconnectingOutbox(httpEventStore, scope)
|
||||||
|
*
|
||||||
|
* scope.launch {
|
||||||
|
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) }
|
||||||
|
* }
|
||||||
|
* scope.launch {
|
||||||
|
* outbox.connectionStatus().collect { s -> ui.showStatus(s) }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // На выходе:
|
||||||
|
* outbox.close() // отменяет background-loop, эмитит Cancelled-как-Disconnected
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Создатель передаёт свой [scope] — жизненный цикл reconnect-цикла
|
||||||
|
* привязан к нему. Закрытие scope (или явный [close]) отменяет
|
||||||
|
* background-loop. После [close] оба flow терминируются.
|
||||||
|
*/
|
||||||
|
class ReconnectingOutbox(
|
||||||
|
private val outbox: OutboxStore,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val policy: BackoffPolicy = BackoffPolicy.Default,
|
||||||
|
private val random: Random = Random.Default,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<CommonEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
private val _status = MutableSharedFlow<ConnectionStatus>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
|
||||||
|
* учитывается только при первом вызове (любом из [events] /
|
||||||
|
* [connectionStatus]). После reconnect курсор берётся из `date`
|
||||||
|
* последнего виденного события.
|
||||||
|
*
|
||||||
|
* Коллекторы независимы — каждый получает свою копию потока (shared).
|
||||||
|
* Медленный коллектор может пропускать события при переполнении буфера
|
||||||
|
* (`DROP_OLDEST`).
|
||||||
|
*/
|
||||||
|
fun events(after: Instant? = null): Flow<CommonEvent> {
|
||||||
|
ensureStarted(after)
|
||||||
|
return _events
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle подключения: [ConnectionStatus.Connecting] /
|
||||||
|
* [ConnectionStatus.Connected] / [ConnectionStatus.Disconnected] /
|
||||||
|
* [ConnectionStatus.Failed]. **Не смешивается** с [events] — это
|
||||||
|
* отдельный поток для UI-индикации статуса сети.
|
||||||
|
*/
|
||||||
|
fun connectionStatus(): Flow<ConnectionStatus> {
|
||||||
|
ensureStarted(null)
|
||||||
|
return _status
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private fun ensureStarted(initialCursor: Instant?) {
|
||||||
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
lastSeen.store(initialCursor)
|
||||||
|
job = scope.launch { runLoop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
var attempt = 0
|
||||||
|
var connected = false
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
attempt++
|
||||||
|
_status.emit(ConnectionStatus.Connecting(attempt))
|
||||||
|
val error: Throwable? = try {
|
||||||
|
outbox.events(after = lastSeen.load()).collect { event ->
|
||||||
|
lastSeen.store(event.date)
|
||||||
|
_events.emit(event)
|
||||||
|
if (!connected) {
|
||||||
|
connected = true
|
||||||
|
_status.emit(ConnectionStatus.Connected(event.date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (t: CancellationException) {
|
||||||
|
throw t
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
t
|
||||||
|
}
|
||||||
|
connected = false
|
||||||
|
if (attempt >= policy.maxAttempts) {
|
||||||
|
_status.emit(
|
||||||
|
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val backoff = computeBackoff(attempt)
|
||||||
|
_status.emit(ConnectionStatus.Disconnected(error, backoff))
|
||||||
|
delay(backoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeBackoff(attempt: Int): Duration {
|
||||||
|
// attempt 1 → initial, 2 → initial * m, 3 → initial * m^2, ...
|
||||||
|
val base = (policy.initial.inWholeMilliseconds.toDouble() *
|
||||||
|
policy.multiplier.pow((attempt - 1).toDouble()))
|
||||||
|
.toLong()
|
||||||
|
val capped = min(base, policy.max.inWholeMilliseconds)
|
||||||
|
val jitterMs = (capped * policy.jitter * random.nextDouble()).toLong()
|
||||||
|
val finalMs = (capped + jitterMs).coerceAtLeast(1L)
|
||||||
|
return Duration.parse("${finalMs}ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import io.ktor.client.request.HttpRequestBuilder
|
|||||||
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||||
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
|
||||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||||
*
|
*
|
||||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тесты клиентской части: [applyAgentikDefaults] с заданным `token` прикладывает
|
* Тесты клиентской части: [agentikHttpClient] с заданным `token` прикладывает
|
||||||
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
||||||
* без токена — заголовок не отправляется.
|
* без токена — заголовок не отправляется.
|
||||||
*
|
*
|
||||||
@@ -50,7 +50,7 @@ class BearerHeaderTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clientWith(token: String?): HttpClient =
|
private fun clientWith(token: String?): HttpClient =
|
||||||
HttpClient(CIO) { applyAgentikDefaults(token) }
|
agentikHttpClient(engineFactory = CIO, token = token)
|
||||||
|
|
||||||
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
||||||
val server = embeddedServer(ServerCIO, port = 0) {
|
val server = embeddedServer(ServerCIO, port = 0) {
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [OutboxStore] для unit-тестов [ReconnectingOutbox].
|
||||||
|
*
|
||||||
|
* Управление:
|
||||||
|
* - [push] — кладёт [CommonEvent] в очередь, флоу доставит.
|
||||||
|
* - [throwAtNextEvent] — следующий «тик» `events(after)` бросит этот Throwable
|
||||||
|
* (симулирует network error / stream break).
|
||||||
|
*
|
||||||
|
* Сигнатура [events] идентична боевой — её можно подменить боевым
|
||||||
|
* `HttpEventStore`, контракт один и тот же.
|
||||||
|
*/
|
||||||
|
internal class FakeOutbox : OutboxStore {
|
||||||
|
private sealed interface Msg {
|
||||||
|
data class Ev(val event: CommonEvent) : Msg
|
||||||
|
data class Err(val throwable: Throwable) : Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
private val channel = Channel<Msg>(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||||
|
for (msg in channel) {
|
||||||
|
when (msg) {
|
||||||
|
is Msg.Err -> throw msg.throwable
|
||||||
|
is Msg.Ev -> emit(msg.event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
|
||||||
|
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
|
||||||
|
|
||||||
|
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow()
|
||||||
|
override fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String?,
|
||||||
|
): Flow<CommonEvent.Conversation> = emptyFlow()
|
||||||
|
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||||
|
override fun close() { channel.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testEvent(dateMs: Long): CommonEvent =
|
||||||
|
CommonEvent.Conversation(
|
||||||
|
date = Instant.fromEpochMilliseconds(dateMs),
|
||||||
|
conversationId = "test",
|
||||||
|
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ReconnectingOutboxTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first event after connect emits Connecting then Connected`() = runConnectionTest(
|
||||||
|
attempts = 5,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
val status = ctx.statusLog
|
||||||
|
val events = ctx.eventsLog
|
||||||
|
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
assertEquals(1, status.count { it is ConnectionStatus.Connecting && it.attempt == 1 })
|
||||||
|
assertEquals(1, status.count { it is ConnectionStatus.Connected })
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disconnect mid-stream triggers retry with backoff and resumes from last seen`() =
|
||||||
|
runConnectionTest(attempts = 5) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
val status = ctx.statusLog
|
||||||
|
val events = ctx.eventsLog
|
||||||
|
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
|
||||||
|
// Имитируем обрыв стрима после первого события.
|
||||||
|
fake.throwAtNextEvent(RuntimeException("simulated network error"))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
// После Disconnected должен прийти Connecting(2), затем Connected,
|
||||||
|
// затем новые события без дубля предыдущего.
|
||||||
|
val disconnectedIndex = status.indexOfFirst { it is ConnectionStatus.Disconnected }
|
||||||
|
val connecting2Index = status.indexOfFirst {
|
||||||
|
it is ConnectionStatus.Connecting && it.attempt == 2
|
||||||
|
}
|
||||||
|
assertTrue(disconnectedIndex >= 0, "no Disconnected emitted, got: $status")
|
||||||
|
assertTrue(connecting2Index > disconnectedIndex,
|
||||||
|
"expected Connecting(2) after Disconnected, got: $status")
|
||||||
|
|
||||||
|
// Push a new event with later date — cursor preserves lastSeen.
|
||||||
|
fake.push(testEvent(2000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
assertEquals(2, events.size)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(2000), events[1].date)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exhausted attempts emits Failed and closes flow`() = runConnectionTest(
|
||||||
|
attempts = 3,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
|
||||||
|
// Каждая попытка connect бросает — все 3 попытки fail.
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
fake.throwAtNextEvent(RuntimeException("server is dead #${i + 1}"))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
val failed = ctx.statusLog.filterIsInstance<ConnectionStatus.Failed>().firstOrNull()
|
||||||
|
assertNotNull(failed) { "expected Failed status, got: ${ctx.statusLog}" }
|
||||||
|
assertTrue(failed.cause is RuntimeException)
|
||||||
|
assertEquals(0, ctx.eventsLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `close cancels background loop`() = runConnectionTest(
|
||||||
|
attempts = 5,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
assertEquals(1, ctx.eventsLog.size)
|
||||||
|
|
||||||
|
ctx.recon.close()
|
||||||
|
ctx.advanceAndDrain(100)
|
||||||
|
|
||||||
|
// После close запуск новых эмиссий не должен происходить.
|
||||||
|
val beforePush = ctx.eventsLog.size
|
||||||
|
fake.push(testEvent(2000))
|
||||||
|
ctx.advanceAndDrain(100)
|
||||||
|
assertEquals(beforePush, ctx.eventsLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TestCtx(
|
||||||
|
val fake: FakeOutbox,
|
||||||
|
val recon: ReconnectingOutbox,
|
||||||
|
val statusLog: MutableList<ConnectionStatus>,
|
||||||
|
val eventsLog: MutableList<CommonEvent>,
|
||||||
|
val jobs: List<Job>,
|
||||||
|
val scope: CoroutineScope,
|
||||||
|
val advanceAndDrain: (Long) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает [ReconnectingOutbox] с policy из `attempts` попыток по 10ms,
|
||||||
|
* сабскрайбит на оба потока в собирающие лист, и возвращает [TestCtx]
|
||||||
|
* с управляемым `advanceAndDrain(ms)` — прокрутить виртуальное время.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private fun runConnectionTest(
|
||||||
|
attempts: Int,
|
||||||
|
block: suspend (TestCtx) -> Unit,
|
||||||
|
) = runTest {
|
||||||
|
val policy = BackoffPolicy.Fixed(
|
||||||
|
delay = Duration.parse("10ms"),
|
||||||
|
attempts = attempts,
|
||||||
|
)
|
||||||
|
val fake = FakeOutbox()
|
||||||
|
val recon = ReconnectingOutbox(
|
||||||
|
outbox = fake,
|
||||||
|
scope = this,
|
||||||
|
policy = policy,
|
||||||
|
)
|
||||||
|
val statusLog = mutableListOf<ConnectionStatus>()
|
||||||
|
val eventsLog = mutableListOf<CommonEvent>()
|
||||||
|
val jobs = listOf(
|
||||||
|
launch { recon.connectionStatus().collect { statusLog.add(it) } },
|
||||||
|
launch { recon.events().collect { eventsLog.add(it) } },
|
||||||
|
)
|
||||||
|
val advanceAndDrain: (Long) -> Unit = { ms ->
|
||||||
|
if (ms > 0) advanceTimeBy(ms)
|
||||||
|
runCurrent()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
TestCtx(fake, recon, statusLog, eventsLog, jobs, this, advanceAndDrain).also { block(it) }
|
||||||
|
} finally {
|
||||||
|
recon.close()
|
||||||
|
jobs.forEach { it.cancel() }
|
||||||
|
fake.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API для runtime context агента (compaction, order_idx, summary entries).
|
||||||
|
// Зависит от :journal-api для типов `Content` / `MessageContext` (audit-log
|
||||||
|
// payload'ы, которые рабочая память ссылает).
|
||||||
|
//
|
||||||
|
// НЕ нужен тонким клиентам — только серверному рантайму (`:standalone`, `:agentik-cli`,
|
||||||
|
// будущий `:android-agent` core).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.context
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
||||||
* Агент не должен с этим типом работать напрямую — он работает с
|
* Агент не должен с этим типом работать напрямую — он работает с
|
||||||
* [WorkingMemoryEntry] через [WorkingMemoryStore].
|
* [WorkingMemoryEntry] через [ContextStore].
|
||||||
*/
|
*/
|
||||||
data class WorkingMemoryRow(
|
data class WorkingMemoryRow(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
|
|||||||
*
|
*
|
||||||
* Суммаризация / чистка — один атомарный вызов [compact].
|
* Суммаризация / чистка — один атомарный вызов [compact].
|
||||||
*/
|
*/
|
||||||
interface WorkingMemoryStore : AutoCloseable {
|
interface ContextStore : AutoCloseable {
|
||||||
|
|
||||||
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.context
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.journal.MessageContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация :context-api (ContextStore) поверх ksqlite.
|
||||||
|
// Минимальная — только таблица `working_memory` + 2 индекса по ней.
|
||||||
|
// Остальные таблицы (`conversation`, `message`, `reflection`) живут в
|
||||||
|
// других ksqlite-модулях; этот модуль не претендует на полную схему
|
||||||
|
// агента.
|
||||||
|
//
|
||||||
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
|
// на Linux (см. KDoc :storage-ksqlite).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
api(project(":context-api"))
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(project(":reflection-api"))
|
||||||
|
// :context-api ссылается на Content / MessageContext из
|
||||||
|
// :message-log-api (старый canonical). Транзитивно через api,
|
||||||
|
// но фиксируем явно чтобы тестовый код видел Content без
|
||||||
|
// обхода через :context-api.
|
||||||
|
// api(project(":message-log-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.context.ContextStore
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryRow
|
||||||
|
import pw.binom.agentik.journal.Ids
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
|
||||||
|
*
|
||||||
|
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
|
||||||
|
* из `:storage-ksqlite`, но:
|
||||||
|
* - лежит в собственном модуле `:context-ksqlite`;
|
||||||
|
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
|
||||||
|
* теперь главный класс — `ContextStore`); сами типы строк
|
||||||
|
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
|
||||||
|
*
|
||||||
|
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
|
||||||
|
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
|
||||||
|
*/
|
||||||
|
class KsqliteContextStore(
|
||||||
|
private val connection: SQLiteConnection,
|
||||||
|
) : ContextStore {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
|
||||||
|
// SIGSEGV в StmtHolder.finalize на закрытой connection).
|
||||||
|
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||||
|
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
ORDER BY ${Schema.COL_ORDER_IDX} ASC
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
|
)
|
||||||
|
private val maxOrderIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
|
||||||
|
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val dropFromIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val insertSummaryStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, NULL, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
val newIdx = maxOrderIdx(conversationId) + 1
|
||||||
|
insertStmt.reset()
|
||||||
|
insertStmt.clearBindings()
|
||||||
|
insertStmt.bindText(1, Ids.new("wm"))
|
||||||
|
insertStmt.bindText(2, conversationId)
|
||||||
|
insertStmt.bindLong(3, newIdx)
|
||||||
|
val srcId = entry.sourceMessageId
|
||||||
|
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
|
||||||
|
insertStmt.bindText(5, entryKind(entry))
|
||||||
|
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
|
||||||
|
insertStmt.bindLong(7, now.toEpochMilliseconds())
|
||||||
|
insertStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listStmt.reset()
|
||||||
|
listStmt.clearBindings()
|
||||||
|
listStmt.bindText(1, conversationId)
|
||||||
|
val out = mutableListOf<WorkingMemoryRow>()
|
||||||
|
listStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
out.add(
|
||||||
|
WorkingMemoryRow(
|
||||||
|
id = rs.getText(0)!!,
|
||||||
|
conversationId = rs.getText(1)!!,
|
||||||
|
orderIdx = rs.getLong(2)!!,
|
||||||
|
sourceMessageId = rs.getText(3),
|
||||||
|
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
clearStmt.reset()
|
||||||
|
clearStmt.clearBindings()
|
||||||
|
clearStmt.bindText(1, conversationId)
|
||||||
|
clearStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun compact(
|
||||||
|
dropFromOrderIdx: Long,
|
||||||
|
conversationId: String,
|
||||||
|
summaryText: String?,
|
||||||
|
): Long = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
var newMax = 0L
|
||||||
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
|
val summaryId = Ids.new("wm")
|
||||||
|
connection.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
dropFromIdxStmt.reset()
|
||||||
|
dropFromIdxStmt.clearBindings()
|
||||||
|
dropFromIdxStmt.bindText(1, conversationId)
|
||||||
|
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
|
||||||
|
dropFromIdxStmt.executeUpdate()
|
||||||
|
|
||||||
|
if (!summaryText.isNullOrBlank()) {
|
||||||
|
val afterDelete = maxOrderIdx(conversationId)
|
||||||
|
val newIdx = afterDelete + 1
|
||||||
|
insertSummaryStmt.reset()
|
||||||
|
insertSummaryStmt.clearBindings()
|
||||||
|
insertSummaryStmt.bindText(1, summaryId)
|
||||||
|
insertSummaryStmt.bindText(2, conversationId)
|
||||||
|
insertSummaryStmt.bindLong(3, newIdx)
|
||||||
|
insertSummaryStmt.bindText(4, "summary")
|
||||||
|
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
|
||||||
|
insertSummaryStmt.bindLong(6, nowMs)
|
||||||
|
insertSummaryStmt.executeUpdate()
|
||||||
|
newMax = newIdx
|
||||||
|
} else {
|
||||||
|
newMax = maxOrderIdx(conversationId)
|
||||||
|
}
|
||||||
|
connection.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { connection.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
newMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
insertStmt.close()
|
||||||
|
listStmt.close()
|
||||||
|
clearStmt.close()
|
||||||
|
maxOrderIdxStmt.close()
|
||||||
|
dropFromIdxStmt.close()
|
||||||
|
insertSummaryStmt.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxOrderIdx(conversationId: String): Long {
|
||||||
|
maxOrderIdxStmt.reset()
|
||||||
|
maxOrderIdxStmt.clearBindings()
|
||||||
|
maxOrderIdxStmt.bindText(1, conversationId)
|
||||||
|
maxOrderIdxStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0) ?: 0L
|
||||||
|
}
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
|
||||||
|
is WorkingMemoryEntry.User -> "user"
|
||||||
|
is WorkingMemoryEntry.Assistant -> "assistant"
|
||||||
|
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
|
||||||
|
is WorkingMemoryEntry.Summary -> "summary"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:context-api`.
|
||||||
|
*
|
||||||
|
* Минимум — только то, что относится к `working_memory` (реализация
|
||||||
|
* [KsqliteContextStore]). Остальные таблицы агента (`conversation`,
|
||||||
|
* `message`, `reflection`) живут в других ksqlite-модулях.
|
||||||
|
*
|
||||||
|
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||||
|
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||||
|
*/
|
||||||
|
internal object Schema {
|
||||||
|
|
||||||
|
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||||
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
|
// ───── Таблица ─────
|
||||||
|
const val TABLE_WORKING_MEMORY = "working_memory"
|
||||||
|
|
||||||
|
// ───── Колонки ─────
|
||||||
|
const val COL_ID = "id"
|
||||||
|
const val COL_CONVERSATION_ID = "conversation_id"
|
||||||
|
const val COL_ORDER_IDX = "order_idx"
|
||||||
|
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
|
||||||
|
const val COL_KIND = "kind"
|
||||||
|
const val COL_PAYLOAD_JSON = "payload_json"
|
||||||
|
const val COL_CREATED_AT = "created_at"
|
||||||
|
|
||||||
|
// ───── Индексы ─────
|
||||||
|
const val IDX_WM_UNIQUE = "idx_wm_unique"
|
||||||
|
const val IDX_WM_CONV = "idx_wm_conv"
|
||||||
|
|
||||||
|
private val v1Ddl = """
|
||||||
|
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
|
||||||
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||||
|
$COL_ORDER_IDX INTEGER NOT NULL,
|
||||||
|
$COL_SOURCE_MESSAGE_ID TEXT,
|
||||||
|
$COL_KIND TEXT NOT NULL,
|
||||||
|
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||||
|
$COL_CREATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val v1IndexesDdl = """
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
|
||||||
|
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||||
|
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
|
||||||
|
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||||
|
*
|
||||||
|
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
|
||||||
|
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
|
||||||
|
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
|
||||||
|
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
|
||||||
|
*
|
||||||
|
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||||
|
*/
|
||||||
|
fun migrate(conn: SQLiteConnection) {
|
||||||
|
val current = readUserVersion(conn)
|
||||||
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
|
conn.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
if (current < 1) {
|
||||||
|
conn.exec(v1Ddl)
|
||||||
|
conn.exec(v1IndexesDdl)
|
||||||
|
}
|
||||||
|
// future: if (current < 2) { conn.exec(v2Ddl) }
|
||||||
|
writeUserVersion(conn, CURRENT_VERSION)
|
||||||
|
conn.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||||
|
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
||||||
|
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
|
||||||
|
// поэтому собираем SQL строкой (значение контролируемое, не user input).
|
||||||
|
conn.exec("PRAGMA user_version = $version")
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для [KsqliteContextStore] — точная копия
|
||||||
|
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием
|
||||||
|
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для
|
||||||
|
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
|
||||||
|
* та же).
|
||||||
|
*
|
||||||
|
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
|
||||||
|
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
|
||||||
|
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
|
||||||
|
* автономный.
|
||||||
|
*/
|
||||||
|
class KsqliteContextStoreTest {
|
||||||
|
|
||||||
|
private lateinit var conn: SQLiteConnection
|
||||||
|
private lateinit var store: KsqliteContextStore
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
|
||||||
|
Schema.migrate(conn)
|
||||||
|
store = KsqliteContextStore(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
store.close()
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User =
|
||||||
|
WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content)))
|
||||||
|
|
||||||
|
private fun asstMsg(content: String): WorkingMemoryEntry.Assistant =
|
||||||
|
WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content)))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendAndListReturnsInOrder() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("first"), t)
|
||||||
|
store.append("c1", asstMsg("reply"), t)
|
||||||
|
val list = store.list("c1")
|
||||||
|
assertEquals(2, list.size)
|
||||||
|
assertEquals(1L, list[0].orderIdx)
|
||||||
|
assertEquals(2L, list[1].orderIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListIsolatesConversations() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("c1-msg"), t)
|
||||||
|
store.append("c2", userMsg("c2-msg"), t)
|
||||||
|
assertEquals(1, store.list("c1").size)
|
||||||
|
assertEquals(1, store.list("c2").size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testClearRemovesAllForConversation() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
store.append("c1", userMsg("b"), t)
|
||||||
|
store.clear("c1")
|
||||||
|
assertEquals(emptyList(), store.list("c1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompactDeletesAndInsertsSummary() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
store.append("c1", userMsg("b"), t)
|
||||||
|
store.append("c1", userMsg("c"), t)
|
||||||
|
// dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a).
|
||||||
|
// Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2.
|
||||||
|
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary")
|
||||||
|
assertEquals(2L, newMax)
|
||||||
|
val remaining = store.list("c1")
|
||||||
|
assertEquals(2, remaining.size)
|
||||||
|
assertEquals(1L, remaining[0].orderIdx)
|
||||||
|
assertEquals(2L, remaining[1].orderIdx)
|
||||||
|
assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompactWithoutSummaryKeepsTailBelow() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
// dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1.
|
||||||
|
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
|
||||||
|
assertEquals(1L, newMax)
|
||||||
|
val remaining = store.list("c1")
|
||||||
|
assertEquals(1, remaining.size)
|
||||||
|
assertEquals(1L, remaining[0].orderIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ Main.kt
|
|||||||
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
||||||
summarization-вставка отложена (нужен дизайн-проработка).
|
summarization-вставка отложена (нужен дизайн-проработка).
|
||||||
|
|
||||||
**`MessageStore`** — append-only аудит. На каждый ход дописываются
|
**`JournalStore`** — append-only аудит. На каждый ход дописываются
|
||||||
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
||||||
update/delete кроме каскада из `ConversationStore.delete`.
|
update/delete кроме каскада из `ConversationStore.delete`.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -175,7 +175,7 @@ fun main() {
|
|||||||
|
|
||||||
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
||||||
|
|
||||||
### `MessageStore`
|
### `JournalStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun append(record: MessageRecord)
|
suspend fun append(record: MessageRecord)
|
||||||
@@ -183,7 +183,7 @@ suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int
|
|||||||
suspend fun listAll(conversationId: String): List<MessageRecord>
|
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||||
```
|
```
|
||||||
|
|
||||||
### `WorkingMemoryStore`
|
### `ContextStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# `:event-store` — bounded-tail event log (KMP)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Двухуровневое хранилище событий агента. Этот модуль — **короткий
|
||||||
|
bounded tail** для live-SSE и недавнего replay. Полный audit log
|
||||||
|
живёт в `:message-store-api` (никогда не эвиктится, source of truth).
|
||||||
|
|
||||||
|
Три принципа:
|
||||||
|
|
||||||
|
1. **Tail управляет TTL сам.** Никаких `prune`/`cleanup` методов наружу —
|
||||||
|
implementation решает, когда выкинуть старый event. Caller'ы не
|
||||||
|
могут забыть cleanup.
|
||||||
|
2. **Catchup + live в одном Flow.** `events(after)` сначала отдаёт
|
||||||
|
буферизованный диапазон, потом переключается на live tail — клиент
|
||||||
|
не должен знать, где у него "разрыв".
|
||||||
|
3. **Read-only контракт для consumer'ов.** Запись через
|
||||||
|
[MutableEventStore], чтение через [EventStore]. Compile-time
|
||||||
|
гарантия что observer не сможет писать в store.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` ChatAgent — append через `MutableOutboxStore` (заменяет
|
||||||
|
текущий `agentEvents: MutableSharedFlow` + `persistAgentEvent`).
|
||||||
|
- `:server` Routes.kt — `/events/all` SSE endpoint читает через
|
||||||
|
`EventStore.events(after)`.
|
||||||
|
- Будущий `:android-agent` core — same интерфейс для локального
|
||||||
|
bounded tail без dedicated server connection.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ :event-store (этот модуль) ────────────────────────┐
|
||||||
|
│ Bounded tail с auto-TTL: │
|
||||||
|
│ • append(event) ← producer │
|
||||||
|
│ • events(after): Flow ← consumer │
|
||||||
|
│ • earliestEventDate() для gap detection │
|
||||||
|
│ TTL/cap eviction — внутри impl │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
▲ gap detected
|
||||||
|
│
|
||||||
|
┌─ :message-store-api (полный audit log) ───────────┐
|
||||||
|
│ MessageStore: query(after, before, limit) │
|
||||||
|
│ Никогда не эвиктится. Source of truth. │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reconnect pattern** (caller делает):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val earliest = eventStore.earliestEventDate()
|
||||||
|
if (client.lastSeen < earliest) {
|
||||||
|
// gap: догоняем через :message-store-api
|
||||||
|
val gap = messageStore.query(after = client.lastSeen, before = earliest)
|
||||||
|
applyAll(gap)
|
||||||
|
client.lastSeen = gap.last().createdAt
|
||||||
|
}
|
||||||
|
eventStore.events(after = client.lastSeen).collect { apply(it) }
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `OutboxStore` (read-only, для consumer'ов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface EventStore : AutoCloseable {
|
||||||
|
fun events(after: Instant?): Flow<CommonEvent>
|
||||||
|
fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String? = null, // null = все диалоги
|
||||||
|
): Flow<CommonEvent.Conversation>
|
||||||
|
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent>
|
||||||
|
suspend fun earliestEventDate(): Instant // non-null: now() для пустого буфера
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Семантика фильтров**:
|
||||||
|
- `conversationEvents(null)` — все диалоги.
|
||||||
|
- `conversationEvents("c-123")` — один конкретный диалог.
|
||||||
|
- `agentEvents(...)` — только lifecycle (Created/Deleted/Renamed).
|
||||||
|
|
||||||
|
Все три возвращают **типизированные** subtype'ы [CommonEvent], так что
|
||||||
|
caller'у не нужно `.filterIsInstance` на клиентской стороне.
|
||||||
|
|
||||||
|
### `MutableEventStore : EventStore` (для producer'ов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface MutableEventStore : EventStore {
|
||||||
|
suspend fun append(event: CommonEvent)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
|
||||||
|
retry даст дубликат. Для exactly-once — dedup через
|
||||||
|
`:message-store-api` (там есть монотонный `id`).
|
||||||
|
|
||||||
|
**Silently evicted**: implementation может выкинуть event сразу после
|
||||||
|
append по TTL/cap. Producer не должен полагаться на то, что event
|
||||||
|
дойдёт до клиента, если он вне retention window.
|
||||||
|
|
||||||
|
## Когда использовать какой интерфейс
|
||||||
|
|
||||||
|
| Caller | Method |
|
||||||
|
|---|---|
|
||||||
|
| Server `/events/all` SSE (mixed) | `events(after)` |
|
||||||
|
| Server `/conversations/{id}/events` SSE | `conversationEvents(after, conversationId)` |
|
||||||
|
| Server `/agent/events` SSE (lifecycle only) | `agentEvents(after)` |
|
||||||
|
| Admin dashboard (lifecycle) | `agentEvents(after)` |
|
||||||
|
| Parent orchestrator (mixed) | `events(after)` |
|
||||||
|
| Тесты | `events(after)` + projection через фильтр |
|
||||||
|
|
||||||
|
## Как добавить новый implementation
|
||||||
|
|
||||||
|
1. Создать класс с конструктором и lifecycle (`close()` обязан
|
||||||
|
освободить ресурсы).
|
||||||
|
2. Реализовать минимум: append (с TTL eviction), events (Flow с
|
||||||
|
catchup + live), earliestEventDate (non-null Instant, now() если
|
||||||
|
буфер пуст).
|
||||||
|
3. Для persistent impl: SQL/ksqlite таблица с индексом по date,
|
||||||
|
вставка = `INSERT OR IGNORE` для дедупликации на уровне БД
|
||||||
|
(если в схеме будет id).
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
- ✅ Interface дизайн (`OutboxStore` + `MutableOutboxStore`)
|
||||||
|
- ✅ KMP build (jvm + linuxX64 + mingwX64)
|
||||||
|
- ⏳ Нет implementations (next: `InMemoryEventStore` для тестов)
|
||||||
|
- ⏳ Не интегрирован в `:standalone`/`:server`
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
- `:proto` (api) — тип `CommonEvent` (3 AgentEvent + 9 Conversation.Event вариантов).
|
||||||
|
- `kotlinx-coroutines-core` (api) — `Flow`.
|
||||||
|
|
||||||
|
Никаких `kotlinx-serialization`, `kotlin-logging`, platform-specific
|
||||||
|
зависимостей — этот модуль намеренно minimal.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-интерфейс bounded-tail event log'а. Implementation-specific TTL/cap
|
||||||
|
// eviction — caller's responsibility НЕ вызывать cleanup() (метод не существует).
|
||||||
|
//
|
||||||
|
// Тип [AllEvent] из :proto — typed envelope (3 AgentEvent + 9 Conversation.Event
|
||||||
|
// вариантов). :event-store отвечает за bounded-tail с auto-TTL, но не за
|
||||||
|
// сериализацию envelope'а — это делает :proto (уже @Serializable).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":proto"))
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ litert = "8"
|
|||||||
sqldelight = "2.3.2"
|
sqldelight = "2.3.2"
|
||||||
shadow = "8.3.5"
|
shadow = "8.3.5"
|
||||||
jvector = "3.0.6"
|
jvector = "3.0.6"
|
||||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
text-embedding-kmp = "5"
|
||||||
kotlin-logging = "3.0.5"
|
kotlin-logging = "3.0.5"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
mosaic = "0.18.0"
|
mosaic = "0.18.0"
|
||||||
@@ -97,8 +97,15 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
|
|||||||
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||||
|
|
||||||
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||||
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
|
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
|
||||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
|
# (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
|
||||||
|
# Версия 5 — первый релиз с реальными нативными klib-вариантами в caffeine
|
||||||
|
# (v4 имел только jvm+android, что ломало native-resolve в :memory-md-vector).
|
||||||
|
# Используется из :memory-md-vector и :memory-vector напрямую через
|
||||||
|
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
|
||||||
|
# нужный variant под target).
|
||||||
|
# `siglip` — JVM+Android only (onnx-runtime), подключается в jvmMain.
|
||||||
|
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "text-embedding-kmp" }
|
||||||
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
||||||
|
|
||||||
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-9
@@ -1,17 +1,12 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Часть контента сообщения на уровне хранилища.
|
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
|
||||||
*
|
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
|
||||||
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
|
* в `Mapping.kt` storage impl'ов.
|
||||||
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
|
|
||||||
* идентичны, но даёт возможность заменить transport-протокол без миграции
|
|
||||||
* таблиц.
|
|
||||||
*
|
|
||||||
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface Content {
|
sealed interface Content {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@@ -11,5 +11,4 @@ import kotlin.uuid.Uuid
|
|||||||
*/
|
*/
|
||||||
object Ids {
|
object Ids {
|
||||||
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
|
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
|
||||||
fun reflection(): String = new("refl")
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log сообщений — read-only представление.
|
||||||
|
*
|
||||||
|
* Producer-операция [MutableJournalStore.append] находится на
|
||||||
|
* [MutableJournalStore] — этот интерфейс только для чтения, чтобы
|
||||||
|
* consumer'ы физически не могли писать в audit log.
|
||||||
|
*
|
||||||
|
* Никаких обновлений, никакого удаления (кроме каскадного вместе
|
||||||
|
* с ConversationStore.delete).
|
||||||
|
*/
|
||||||
|
interface JournalStore : AutoCloseable {
|
||||||
|
|
||||||
|
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
||||||
|
* (по странице через `list()` пока не получит короткую страницу). Для
|
||||||
|
* in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть
|
||||||
|
* на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip.
|
||||||
|
*/
|
||||||
|
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
|
||||||
|
var offset = 0
|
||||||
|
while (true) {
|
||||||
|
val page = list(conversationId, after, offset, pageSize)
|
||||||
|
if (page.isEmpty()) return@flow
|
||||||
|
for (rec in page) emit(rec)
|
||||||
|
if (page.size < pageSize) return@flow
|
||||||
|
offset += page.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PAGE_SIZE = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageContext(
|
||||||
|
val origin: MessageOrigin,
|
||||||
|
val sourceId: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val metadata: JsonElement? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контекст инициации хода (кто/что и почему). Дубликат типа из `:proto` —
|
||||||
|
* живёт здесь чтобы не тащить `:proto` в слой хранения данных.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class MessageOrigin {
|
||||||
|
@SerialName("user")
|
||||||
|
USER,
|
||||||
|
|
||||||
|
@SerialName("system")
|
||||||
|
SYSTEM,
|
||||||
|
|
||||||
|
@SerialName("event")
|
||||||
|
EVENT,
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись в таблице `message` (append-only audit).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface MessageRecord {
|
||||||
|
val id: String
|
||||||
|
val conversationId: String
|
||||||
|
val createdAt: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface Body : MessageRecord {
|
||||||
|
val content: List<Content>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("user")
|
||||||
|
data class UserMessage(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
override val content: List<Content>,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
) : Body
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("assistant")
|
||||||
|
data class AssistantMessage(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
override val content: List<Content>,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
val tokens: TurnTokens? = null,
|
||||||
|
) : Body
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_call")
|
||||||
|
data class ToolCall(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val toolName: String,
|
||||||
|
val toolTitle: String?,
|
||||||
|
val toolArgsJson: String,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_result")
|
||||||
|
data class ToolResult(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val toolCallId: String,
|
||||||
|
/**
|
||||||
|
* Имя тула, денормализованное из соответствующего `MessageRecord.ToolCall.toolName`.
|
||||||
|
* Денормализация экономна (одна строка в SQLite) и снимает с UI
|
||||||
|
* необходимость сопоставления `toolCallId → toolName`. `null` —
|
||||||
|
* безопасный backfill для записей до миграции или для сиротливых
|
||||||
|
* результатов без предшествующего `ToolCall`.
|
||||||
|
*/
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String?,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("error")
|
||||||
|
data class Error(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val message: String,
|
||||||
|
val code: String?,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable вариант [JournalStore] — добавляет producer-операцию [append].
|
||||||
|
*
|
||||||
|
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
|
||||||
|
* ConversationLoop, ToolDispatcher, sub-agents, A2A-bridge).
|
||||||
|
* Consumer'ы (DebugRoutes, admin dashboards, parent agents) должны
|
||||||
|
* принимать **read-only** [JournalStore] — тогда невозможно случайно
|
||||||
|
* записать в audit log из observer'а.
|
||||||
|
*
|
||||||
|
* **Append семантика**: см. KDoc [MessageStore.append][JournalStore] —
|
||||||
|
* на этом интерфейсе (не дублируем).
|
||||||
|
*/
|
||||||
|
interface MutableJournalStore : JournalStore {
|
||||||
|
suspend fun append(record: MessageRecord)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private val bodyJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageBodyPayload(
|
||||||
|
val content: List<Content>,
|
||||||
|
@SerialName("context")
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
val tokens: TurnTokens? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun encodeBodyPayload(
|
||||||
|
content: List<Content>,
|
||||||
|
context: MessageContext? = null,
|
||||||
|
tokens: TurnTokens? = null,
|
||||||
|
): String = bodyJson.encodeToString(
|
||||||
|
MessageBodyPayload.serializer(),
|
||||||
|
MessageBodyPayload(content = content, context = context, tokens = tokens),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
|
||||||
|
|
||||||
|
data class BodyDecoded(
|
||||||
|
val content: List<Content>,
|
||||||
|
val context: MessageContext?,
|
||||||
|
val tokens: TurnTokens? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun readPayload(json: String): BodyDecoded {
|
||||||
|
return try {
|
||||||
|
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
|
||||||
|
BodyDecoded(p.content, p.context, p.tokens)
|
||||||
|
} catch (e: kotlinx.serialization.SerializationException) {
|
||||||
|
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
|
||||||
|
BodyDecoded(arr, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token usage одного assistant turn'а.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TurnTokens(
|
||||||
|
val input: Int,
|
||||||
|
val output: Int,
|
||||||
|
) {
|
||||||
|
val total: Int get() = input + output
|
||||||
|
init {
|
||||||
|
require(input >= 0) { "input tokens must be non-negative, got $input" }
|
||||||
|
require(output >= 0) { "output tokens must be non-negative, got $output" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация [MutableJournalStore] на `MutableList` + `Mutex` — для
|
||||||
|
// тестов, dev-режима, embedded-сценариев (Android core, CLI, in-process кэш
|
||||||
|
// в клиенте) и как образец для своей реализации.
|
||||||
|
//
|
||||||
|
// `list` фильтрует по `conversationId`+`createdAt>after` и сортирует
|
||||||
|
// по `createdAt ASC`. Paging — поверх отфильтрованного списка.
|
||||||
|
//
|
||||||
|
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":journal-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package pw.binom.agentik.journal.inmemory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простая in-memory [MutableJournalStore] для тестов, dev-режима и
|
||||||
|
* клиентских in-process кэшей.
|
||||||
|
*
|
||||||
|
* **Thread-safety**: `Mutex` поверх `MutableList<MessageRecord>`. Для
|
||||||
|
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
|
||||||
|
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
|
||||||
|
*
|
||||||
|
* **Контракт `list`**: возвращает подмножество с
|
||||||
|
* `conversationId == conversationId && createdAt > after`, отсортированное
|
||||||
|
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка.
|
||||||
|
*
|
||||||
|
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
|
||||||
|
* на сервере). [close] — no-op.
|
||||||
|
*
|
||||||
|
* Типичный кэш-паттерн в клиенте:
|
||||||
|
* ```
|
||||||
|
* val local = InMemoryJournalStore()
|
||||||
|
* val remote = HttpJournalStore(httpClient, baseUrl)
|
||||||
|
* // backfill + кэширование:
|
||||||
|
* remote.listFlow(convId, Instant.DISTANT_PAST).collect { local.append(it) }
|
||||||
|
* // после этого `local.list(convId, after, offset, limit)` отдаёт из кэша.
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class InMemoryJournalStore : MutableJournalStore {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val records: MutableList<MessageRecord> = mutableListOf()
|
||||||
|
|
||||||
|
override suspend fun append(record: MessageRecord): Unit = mutex.withLock {
|
||||||
|
records.add(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> = mutex.withLock {
|
||||||
|
records.asSequence()
|
||||||
|
.filter { it.conversationId == conversationId && it.createdAt > after }
|
||||||
|
.sortedBy { it.createdAt }
|
||||||
|
.drop(offset)
|
||||||
|
.take(limit)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сбросить кэш (например, когда диалог удалён). */
|
||||||
|
suspend fun clear(): Unit = mutex.withLock {
|
||||||
|
records.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
||||||
|
suspend fun size(): Int = mutex.withLock { records.size }
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// no-op: lifecycle HttpClient'а — снаружи.
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package pw.binom.agentik.journal.inmemory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class InMemoryJournalStoreTest {
|
||||||
|
|
||||||
|
private fun userMsg(id: String, convId: String, text: String, at: Instant) =
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
|
||||||
|
createdAt = at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `append then list returns records sorted by createdAt ASC`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "first", t0))
|
||||||
|
store.append(userMsg("m2", "c1", "second", t0 + 1.seconds))
|
||||||
|
store.append(userMsg("m3", "c1", "third", t0 + 2.seconds))
|
||||||
|
|
||||||
|
val all = store.list("c1", Instant.DISTANT_PAST, 0, 100)
|
||||||
|
assertEquals(3, all.size)
|
||||||
|
assertEquals(listOf("m1", "m2", "m3"), all.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list filters by conversationId`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "a", t0))
|
||||||
|
store.append(userMsg("m2", "c2", "b", t0 + 1.seconds))
|
||||||
|
store.append(userMsg("m3", "c1", "c", t0 + 2.seconds))
|
||||||
|
|
||||||
|
assertEquals(2, store.list("c1", Instant.DISTANT_PAST, 0, 100).size)
|
||||||
|
assertEquals(1, store.list("c2", Instant.DISTANT_PAST, 0, 100).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list filters by after cursor`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "a", t0))
|
||||||
|
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
|
||||||
|
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
|
||||||
|
|
||||||
|
val afterT0 = store.list("c1", t0, 0, 100)
|
||||||
|
assertEquals(listOf("m2", "m3"), afterT0.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list applies offset and limit`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
repeat(10) { i -> store.append(userMsg("m$i", "c1", "x", t0 + i.seconds)) }
|
||||||
|
|
||||||
|
val page = store.list("c1", Instant.DISTANT_PAST, offset = 3, limit = 4)
|
||||||
|
assertEquals(listOf("m3", "m4", "m5", "m6"), page.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear empties the cache`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "x", t0))
|
||||||
|
assertEquals(1, store.size())
|
||||||
|
store.clear()
|
||||||
|
assertEquals(0, store.size())
|
||||||
|
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация :journal-api (JournalStore / MutableJournalStore) поверх ksqlite.
|
||||||
|
// Минимальная — только таблица `message` для append-only audit log'а.
|
||||||
|
// ConversationStore / ReflectionStore / WorkingMemoryStore живут в своих
|
||||||
|
// собственных ksqlite-модулях.
|
||||||
|
//
|
||||||
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
|
// на Linux (см. KDoc :storage-ksqlite).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
api(project(":journal-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
|
||||||
|
*
|
||||||
|
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore]
|
||||||
|
* из `:storage-ksqlite`, но:
|
||||||
|
* - лежит в собственном модуле `:journal-ksqlite`;
|
||||||
|
* - реализует переименованный [MutableJournalStore] (раньше был
|
||||||
|
* `MutableMessageStore`, теперь главный класс — `JournalStore` /
|
||||||
|
* `MutableJournalStore`); сам тип записи [MessageRecord] не
|
||||||
|
* переименовывался.
|
||||||
|
*
|
||||||
|
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||||
|
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
|
||||||
|
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
|
||||||
|
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
|
||||||
|
*
|
||||||
|
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
|
||||||
|
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
|
||||||
|
* в [MessageCodecs.kt] рядом.
|
||||||
|
*
|
||||||
|
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
|
||||||
|
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
|
||||||
|
* отдельно.
|
||||||
|
*/
|
||||||
|
class KsqliteJournalStore internal constructor(
|
||||||
|
private val connection: SQLiteConnection,
|
||||||
|
) : MutableJournalStore {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_MESSAGE}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||||
|
FROM ${Schema.TABLE_MESSAGE}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
AND ${Schema.COL_CREATED_AT} > ?
|
||||||
|
ORDER BY ${Schema.COL_CREATED_AT} ASC, ${Schema.COL_ID} ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||||
|
val (kind, payload) = encodeRecord(record)
|
||||||
|
mutex.withLock {
|
||||||
|
insertStmt.reset()
|
||||||
|
insertStmt.clearBindings()
|
||||||
|
insertStmt.bindText(1, record.id)
|
||||||
|
insertStmt.bindText(2, record.conversationId)
|
||||||
|
insertStmt.bindText(3, kind)
|
||||||
|
insertStmt.bindText(4, payload)
|
||||||
|
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
|
||||||
|
insertStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listStmt.reset()
|
||||||
|
listStmt.clearBindings()
|
||||||
|
listStmt.bindText(1, conversationId)
|
||||||
|
listStmt.bindLong(2, after.toEpochMilliseconds())
|
||||||
|
listStmt.bindLong(3, limit.toLong())
|
||||||
|
listStmt.bindLong(4, offset.toLong())
|
||||||
|
val out = mutableListOf<MessageRecord>()
|
||||||
|
listStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) out.add(rs.toMessageRecord(json))
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
clearStmt.reset()
|
||||||
|
clearStmt.clearBindings()
|
||||||
|
clearStmt.bindText(1, conversationId)
|
||||||
|
clearStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
insertStmt.close()
|
||||||
|
listStmt.close()
|
||||||
|
clearStmt.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.decodeBodyPayload
|
||||||
|
import pw.binom.agentik.journal.encodeBodyPayload
|
||||||
|
import pw.binom.db.ksqlite.SQLiteResultSet
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
|
||||||
|
*
|
||||||
|
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers
|
||||||
|
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
|
||||||
|
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
|
||||||
|
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
|
||||||
|
*/
|
||||||
|
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
||||||
|
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
|
||||||
|
content = record.content,
|
||||||
|
context = record.context,
|
||||||
|
)
|
||||||
|
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
|
||||||
|
content = record.content,
|
||||||
|
tokens = record.tokens,
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
||||||
|
CallPayload.serializer(),
|
||||||
|
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
|
||||||
|
ResultPayload.serializer(),
|
||||||
|
ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
|
||||||
|
)
|
||||||
|
is MessageRecord.Error -> "error" to Json.encodeToString(
|
||||||
|
ErrorPayload.serializer(),
|
||||||
|
ErrorPayload(message = record.message, code = record.code),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||||
|
val id = getText(0)!!
|
||||||
|
val convId = getText(1)!!
|
||||||
|
val kind = getText(2)!!
|
||||||
|
val payload = getText(3)!!
|
||||||
|
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
||||||
|
return when (kind) {
|
||||||
|
"user" -> {
|
||||||
|
val d = decodeBodyPayload(payload)
|
||||||
|
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
|
||||||
|
}
|
||||||
|
"assistant" -> {
|
||||||
|
val d = decodeBodyPayload(payload)
|
||||||
|
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
|
||||||
|
}
|
||||||
|
"tool_call" -> {
|
||||||
|
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||||
|
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
|
||||||
|
}
|
||||||
|
"tool_result" -> {
|
||||||
|
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
||||||
|
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||||
|
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
|
||||||
|
}
|
||||||
|
else -> error("Unknown message kind in audit log: $kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
|
||||||
|
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
|
||||||
|
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
|
||||||
|
* без поля десериализуются как `null`.
|
||||||
|
*/
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class ResultPayload(
|
||||||
|
val toolCallId: String,
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class ErrorPayload(val message: String, val code: String?)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
|
||||||
|
*
|
||||||
|
* Минимум — только то, что относится к `message` (append-only audit log).
|
||||||
|
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
|
||||||
|
* живут в других ksqlite-модулях.
|
||||||
|
*
|
||||||
|
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||||
|
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||||
|
*/
|
||||||
|
internal object Schema {
|
||||||
|
|
||||||
|
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||||
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
|
// ───── Таблица ─────
|
||||||
|
const val TABLE_MESSAGE = "message"
|
||||||
|
|
||||||
|
// ───── Колонки ─────
|
||||||
|
const val COL_ID = "id"
|
||||||
|
const val COL_CONVERSATION_ID = "conversation_id"
|
||||||
|
const val COL_KIND = "kind"
|
||||||
|
const val COL_PAYLOAD_JSON = "payload_json"
|
||||||
|
const val COL_CREATED_AT = "created_at"
|
||||||
|
|
||||||
|
// ───── Индексы ─────
|
||||||
|
const val IDX_MSG_CONV = "idx_msg_conv"
|
||||||
|
|
||||||
|
private val v1Ddl = """
|
||||||
|
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
||||||
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||||
|
$COL_KIND TEXT NOT NULL,
|
||||||
|
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||||
|
$COL_CREATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val v1IndexesDdl = """
|
||||||
|
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
|
||||||
|
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||||
|
*
|
||||||
|
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
|
||||||
|
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
|
||||||
|
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
|
||||||
|
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
|
||||||
|
*
|
||||||
|
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||||
|
*/
|
||||||
|
fun migrate(conn: SQLiteConnection) {
|
||||||
|
val current = readUserVersion(conn)
|
||||||
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
|
conn.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
if (current < 1) {
|
||||||
|
conn.exec(v1Ddl)
|
||||||
|
conn.exec(v1IndexesDdl)
|
||||||
|
}
|
||||||
|
// future: if (current < 2) { conn.exec(v2Ddl) }
|
||||||
|
writeUserVersion(conn, CURRENT_VERSION)
|
||||||
|
conn.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||||
|
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
||||||
|
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
|
||||||
|
// поэтому собираем SQL строкой (значение контролируемое, не user input).
|
||||||
|
conn.exec("PRAGMA user_version = $version")
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.TurnTokens
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для [KsqliteJournalStore] — точная копия
|
||||||
|
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов
|
||||||
|
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory
|
||||||
|
* SQLiteConnection + Schema.migrate).
|
||||||
|
*
|
||||||
|
* Тест `testClearRemovesByConversation` из оригинала использовал
|
||||||
|
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
|
||||||
|
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
|
||||||
|
* автономен и не знает про ConversationStore.
|
||||||
|
*/
|
||||||
|
class KsqliteJournalStoreTest {
|
||||||
|
|
||||||
|
private lateinit var conn: SQLiteConnection
|
||||||
|
private lateinit var store: KsqliteJournalStore
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
|
||||||
|
Schema.migrate(conn)
|
||||||
|
store = KsqliteJournalStore(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
store.close()
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendUserAndRetrieve() = runTest {
|
||||||
|
store.append(MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "conv1",
|
||||||
|
content = listOf(Content.Text("hello")),
|
||||||
|
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||||
|
context = null,
|
||||||
|
))
|
||||||
|
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
val msg = list[0]
|
||||||
|
assertEquals("m1", msg.id)
|
||||||
|
assertEquals(MessageRecord.UserMessage::class, msg::class)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendAssistantWithTokens() = runTest {
|
||||||
|
store.append(MessageRecord.AssistantMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "conv1",
|
||||||
|
content = listOf(Content.Text("hi")),
|
||||||
|
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||||
|
tokens = TurnTokens(input = 50, output = 30),
|
||||||
|
))
|
||||||
|
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
val msg = list[0] as MessageRecord.AssistantMessage
|
||||||
|
assertEquals(TurnTokens(input = 50, output = 30), msg.tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListAfterFiltersByTimestamp() = runTest {
|
||||||
|
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||||
|
val t2 = Instant.parse("2026-09-15T10:02:00Z")
|
||||||
|
val t3 = Instant.parse("2026-09-15T10:03:00Z")
|
||||||
|
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
|
||||||
|
|
||||||
|
val after = store.list("conv1", after = t1, offset = 0, limit = 10)
|
||||||
|
assertEquals(2, after.size)
|
||||||
|
assertEquals(listOf("m2", "m3"), after.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListFlowReturnsAllInOrder() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
for (i in 1..3) store.append(
|
||||||
|
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
|
||||||
|
)
|
||||||
|
assertEquals(listOf("m1", "m2", "m3"), store.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testClearRemovesByConversation() = runTest {
|
||||||
|
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), Instant.parse("2026-09-15T10:00:00Z"), null))
|
||||||
|
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), Instant.parse("2026-09-15T10:00:00Z"), null))
|
||||||
|
store.clear("conv1")
|
||||||
|
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
|
||||||
|
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":memory-api"))
|
api(project(":memory-api"))
|
||||||
api(project(":storage-core"))
|
api(project(":journal-api"))
|
||||||
|
api(project(":reflection-api"))
|
||||||
|
api(project(":context-api"))
|
||||||
api(project(":skills"))
|
api(project(":skills"))
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
|
|||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
import pw.binom.agentik.memory.NewMemoryNote
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
import pw.binom.agentik.storage.Ids
|
import pw.binom.agentik.journal.Ids
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import pw.binom.litert.LiteConversationConfig
|
import pw.binom.litert.LiteConversationConfig
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import pw.binom.agentik.storage.Ids
|
import pw.binom.agentik.journal.Ids
|
||||||
import pw.binom.agentik.storage.Reflection
|
import pw.binom.agentik.reflection.Reflection
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ class LlmReflector(
|
|||||||
val parsed = ReflectionParser.parse(raw)
|
val parsed = ReflectionParser.parse(raw)
|
||||||
?: return@withContext null
|
?: return@withContext null
|
||||||
Reflection(
|
Reflection(
|
||||||
id = Ids.reflection(),
|
id = pw.binom.agentik.reflection.Ids.new(),
|
||||||
conversationId = null, // будет проставлен caller'ом ChatConversation
|
conversationId = null, // будет проставлен caller'ом ChatConversation
|
||||||
createdAt = clock.now(),
|
createdAt = clock.now(),
|
||||||
turnsAnalyzed = turns.size,
|
turnsAnalyzed = turns.size,
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
// `TextEmbeddingExecutor` (suspend-обёртка над `TextEmbeddingExtractor`)
|
||||||
|
// живёт в :memory-api с 2026-09-21 — раньше был `EmbeddingProvider` в
|
||||||
|
// :memory-vector, но он JVM-only и блокировал :memory-md-vector от
|
||||||
|
// нативных таргетов. text-embedding-kmp:api собирается под jvm+android+
|
||||||
|
// linux/macos/ios/mingw (мы добавили нативные цели в их :api модуле),
|
||||||
|
// так что KMP-потребители могут зависеть от него напрямую.
|
||||||
|
api(libs.text.embedding.api)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
|||||||
@@ -24,4 +24,11 @@ data class MemoryNote(
|
|||||||
val useCount: Int = 0,
|
val useCount: Int = 0,
|
||||||
val conversationId: String? = null,
|
val conversationId: String? = null,
|
||||||
val source: MemorySource,
|
val source: MemorySource,
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* Дешёвый content-fingerprint: хэш от id + content.
|
||||||
|
* Используется vector-кэшами (`:memory-md-vector`, `:memory-vector`) для
|
||||||
|
* определения "изменилась ли заметка" без re-embed'а.
|
||||||
|
*/
|
||||||
|
fun contentHash(): String = (id.hashCode().toLong() xor content.hashCode().toLong()).toString(16)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score
|
||||||
|
* в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||||
|
*
|
||||||
|
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||||
|
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||||
|
* `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит
|
||||||
|
* от JVM-only `:memory-vector`.
|
||||||
|
*/
|
||||||
|
data class ScoredVector(
|
||||||
|
val id: String,
|
||||||
|
val score: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||||
|
* векторов к query. Метаданные заметок лежат в `MemoryStore` (для
|
||||||
|
* vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`);
|
||||||
|
* индекс хранит только embedding'и + id-маппинг.
|
||||||
|
*
|
||||||
|
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||||
|
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||||
|
* `:memory-md-vector` (KMP).
|
||||||
|
*
|
||||||
|
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||||
|
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации —
|
||||||
|
* это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||||
|
*/
|
||||||
|
interface MemoryVectorIndex : AutoCloseable {
|
||||||
|
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||||
|
val dimension: Int
|
||||||
|
|
||||||
|
/** Количество записей в индексе. */
|
||||||
|
suspend fun size(): Long
|
||||||
|
|
||||||
|
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
|
||||||
|
suspend fun add(id: String, embedding: FloatArray)
|
||||||
|
|
||||||
|
/** Удалить запись по [id]. Возвращает true если запись была. */
|
||||||
|
suspend fun remove(id: String): Boolean
|
||||||
|
|
||||||
|
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id. */
|
||||||
|
suspend fun search(
|
||||||
|
query: FloatArray,
|
||||||
|
k: Int,
|
||||||
|
filter: (MemoryNote) -> Boolean = { true },
|
||||||
|
): List<ScoredVector>
|
||||||
|
|
||||||
|
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
|
||||||
|
suspend fun flush()
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доп. контекст для vector-индекса: фильтр по категории и conversationId.
|
||||||
|
*
|
||||||
|
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с
|
||||||
|
* переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён
|
||||||
|
* сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`.
|
||||||
|
*
|
||||||
|
* Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые
|
||||||
|
* будущие) должны использовать этот хелпер при фильтрации результатов search,
|
||||||
|
* чтобы контракт был единый.
|
||||||
|
*/
|
||||||
|
fun noteMatches(
|
||||||
|
note: MemoryNote,
|
||||||
|
category: MemoryCategory? = null,
|
||||||
|
conversationId: String? = null,
|
||||||
|
): Boolean {
|
||||||
|
if (category != null && note.category != category) return false
|
||||||
|
if (conversationId != null && note.conversationId != conversationId) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`.
|
||||||
|
*
|
||||||
|
* `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP),
|
||||||
|
* поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают
|
||||||
|
* честный suspend, а блокирующая работа уходит в background dispatcher.
|
||||||
|
*
|
||||||
|
* Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI
|
||||||
|
* text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем
|
||||||
|
* его; иначе — определяем лениво по первому [embed] (probe-vector на пустом
|
||||||
|
* тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования,
|
||||||
|
* так что для prod-использования рекомендуется всегда передавать [knownDimension]
|
||||||
|
* явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а).
|
||||||
|
*
|
||||||
|
* @param extractor underlying extractor (не null)
|
||||||
|
* @param knownDimension заранее известная размерность; null = определить по probe
|
||||||
|
*/
|
||||||
|
class TextEmbeddingExecutor(
|
||||||
|
val extractor: TextEmbeddingExtractor,
|
||||||
|
val knownDimension: Int? = null,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
/** Размерность векторов. Эффективно константа после первого обращения. */
|
||||||
|
val dimension: Int by lazy {
|
||||||
|
knownDimension ?: extractor.embed("").dim
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит
|
||||||
|
* в [Dispatchers.Default] — caller может безопасно await'ить.
|
||||||
|
*/
|
||||||
|
suspend fun embed(text: String): FloatArray =
|
||||||
|
withContext(Dispatchers.Default) { extractor.embed(text).values }
|
||||||
|
|
||||||
|
/** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */
|
||||||
|
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
||||||
|
texts.map { embed(it) }
|
||||||
|
|
||||||
|
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
|
||||||
|
override fun close() {
|
||||||
|
extractor.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// :memory-md-vector — гибридное хранилище памяти:
|
||||||
|
//
|
||||||
|
// .md файлы (:memory-md, single source of truth)
|
||||||
|
// ↓ reconcile() на старте
|
||||||
|
// sqlite vector index (ksqlite + sqlite-vec vec0, derived cache)
|
||||||
|
//
|
||||||
|
// `.md` — единственный источник правды по метаданным и тексту заметок.
|
||||||
|
// Вектора — derived cache, перестраивается на старте и при `upsert`/`delete`.
|
||||||
|
//
|
||||||
|
// ANN-поиск: vector KNN (sqlite-vec MATCH) → top-50 → keyword rerank
|
||||||
|
// через `MdMemoryFormat.keywordScore` (vector 0.7 + keyword 0.3).
|
||||||
|
//
|
||||||
|
// Цели сборки — KMP: jvm() + linuxX64() + mingwX64(). До 2026-09-21 был
|
||||||
|
// JVM-only, потому что тащил `EmbeddingProvider` из JVM-only `:memory-vector`.
|
||||||
|
// С переходом на `TextEmbeddingExecutor` (из `:memory-api`, который тянет
|
||||||
|
// `pw.binom.ai.embeddingtext:api` — теперь KMP) модуль стал платформо-
|
||||||
|
// независимым. Под нативом тесты работают с `FakeTextEmbeddingExtractor`;
|
||||||
|
// прод-реализация (`:siglip` модуль text-embedding-kmp) пока JVM+Android only.
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.io.core)
|
||||||
|
|
||||||
|
api(project(":memory-api"))
|
||||||
|
implementation(project(":memory-md"))
|
||||||
|
// `text-embedding-api` тянется транзитивно через `:memory-api`
|
||||||
|
// (мы добавили `api(libs.text.embedding.api)` в memory-api/build.gradle.kts).
|
||||||
|
// Раньше тут стоял `implementation(project(":memory-vector"))` ради
|
||||||
|
// `EmbeddingProvider` — JVM-only модуль с JVector. Теперь не нужен.
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySearchResult
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.agentik.memory.md.MdMemoryFormat
|
||||||
|
import pw.binom.agentik.memory.md.MdMemoryStore
|
||||||
|
import pw.binom.agentik.memory.noteMatches
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Гибридное хранилище памяти:
|
||||||
|
*
|
||||||
|
* * `.md` файлы (через [MdMemoryStore]) — single source of truth по
|
||||||
|
* метаданным и тексту заметок;
|
||||||
|
* * ksqlite vector index ([KsqliteVectorIndex]) — derived cache embeddings
|
||||||
|
* и content_hash.
|
||||||
|
*
|
||||||
|
** Архитектурный контракт:
|
||||||
|
*
|
||||||
|
* 1. Любая мутация (upsert/delete) обновляет оба слоя атомарно: сначала
|
||||||
|
* `.md` (через [MdMemoryStore]), потом векторный кэш. Если vector-write
|
||||||
|
* упал — `.md` уже сохранён; reconcile при следующем старте восстановит
|
||||||
|
* консистентность.
|
||||||
|
*
|
||||||
|
* 2. [search] использует vector ANN (sqlite-vec MATCH) → top-50 → keyword
|
||||||
|
* rerank (`MdMemoryFormat.keywordScore`). Финальный score = 0.7 * vector
|
||||||
|
* + 0.3 * keyword. Это даёт семантический recall с быстрой фильтрацией
|
||||||
|
* по точным совпадениям.
|
||||||
|
*
|
||||||
|
* 3. [reconcile] — вызывается при старте (из [openHybridMemoryStore]):
|
||||||
|
* - .md файл есть, вектора нет → embed + add;
|
||||||
|
* - .md файл есть, вектор есть, content_hash отличается → re-embed;
|
||||||
|
* - .md файла нет, вектор есть → orphan, remove.
|
||||||
|
*
|
||||||
|
* 4. Read-only методы ([get], [list], [markUsed], [archiveStale], [events])
|
||||||
|
* делегируются в [MdMemoryStore] напрямую — никакой транзакции с
|
||||||
|
* vector-кэшем.
|
||||||
|
*
|
||||||
|
* Потокобезопасность: делегирующие методы — thread-safe за счёт
|
||||||
|
* `MdMemoryStore.mu`. Мутации векторов сериализуются
|
||||||
|
* [KsqliteVectorIndex.mutex]. Метод [reconcile] держит свой [mutex] для
|
||||||
|
* исключения конкурентных upsert'ов во время согласования.
|
||||||
|
*/
|
||||||
|
class HybridMdVectorStore internal constructor(
|
||||||
|
private val mdStore: MdMemoryStore,
|
||||||
|
private val vectorIndex: KsqliteVectorIndex,
|
||||||
|
private val embedder: TextEmbeddingExecutor,
|
||||||
|
) : MemoryStore {
|
||||||
|
|
||||||
|
private val reconcileMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отчёт о согласовании `.md` ↔ vector-индекс. Возвращается из [reconcile].
|
||||||
|
*/
|
||||||
|
data class ReconcileReport(
|
||||||
|
val added: Int,
|
||||||
|
val reembedded: Int,
|
||||||
|
val orphansRemoved: Int,
|
||||||
|
) {
|
||||||
|
val totalChanged: Int get() = added + reembedded + orphansRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Согласовать vector-кэш с текущим состоянием `.md` файлов.
|
||||||
|
*
|
||||||
|
* Идемпотентен — повторный вызов no-op.
|
||||||
|
*
|
||||||
|
* Можно вызывать из фонового потока при старте `Main.kt` чтобы
|
||||||
|
* залогировать "reconciled: 5 re-embedded, 2 added, 0 orphans".
|
||||||
|
*/
|
||||||
|
suspend fun reconcile(): ReconcileReport = reconcileMutex.withLock {
|
||||||
|
val onDisk: List<MemoryNote> = mdStore.list(limit = Int.MAX_VALUE)
|
||||||
|
val onDiskById: Map<String, MemoryNote> = onDisk.associateBy { it.id }
|
||||||
|
val inCache: List<KsqliteVectorIndex.MetaEntry> = vectorIndex.allMeta()
|
||||||
|
|
||||||
|
val cachedIds: Set<String> = inCache.map { it.id }.toSet()
|
||||||
|
|
||||||
|
var added = 0
|
||||||
|
var reembedded = 0
|
||||||
|
var orphansRemoved = 0
|
||||||
|
|
||||||
|
// 1) orphan-cleanup: vector есть, .md нет
|
||||||
|
for (cached in inCache) {
|
||||||
|
if (cached.id !in onDiskById) {
|
||||||
|
vectorIndex.remove(cached.id)
|
||||||
|
orphansRemoved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) re-embed / add
|
||||||
|
for (note in onDisk) {
|
||||||
|
val cached = inCache.firstOrNull { it.id == note.id }
|
||||||
|
val currentHash = note.contentHash()
|
||||||
|
if (cached == null) {
|
||||||
|
// .md есть, вектора нет → add
|
||||||
|
val vec = embedder.embed(note.content)
|
||||||
|
vectorIndex.add(note.id, vec, currentHash)
|
||||||
|
added++
|
||||||
|
} else if (cached.contentHash != currentHash) {
|
||||||
|
// .md изменился → re-embed
|
||||||
|
val vec = embedder.embed(note.content)
|
||||||
|
vectorIndex.add(note.id, vec, currentHash)
|
||||||
|
reembedded++
|
||||||
|
}
|
||||||
|
// else: cached.contentHash == currentHash → no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
ReconcileReport(added, reembedded, orphansRemoved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MemoryStore impl: мутации ─────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun upsert(note: MemoryNote) {
|
||||||
|
mdStore.upsert(note)
|
||||||
|
val vec = embedder.embed(note.content)
|
||||||
|
vectorIndex.add(note.id, vec, note.contentHash())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
val existed = mdStore.delete(id)
|
||||||
|
vectorIndex.remove(id)
|
||||||
|
return existed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MemoryStore impl: search (hybrid) ────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
|
||||||
|
if (query.query.isBlank()) return emptyList()
|
||||||
|
if (query.topK <= 0) return emptyList()
|
||||||
|
|
||||||
|
// Этап 1: vector ANN top-K (K=50 или больше topK).
|
||||||
|
val candidateK = maxOf(query.topK, VECTOR_CANDIDATES)
|
||||||
|
val qVec = embedder.embed(query.query)
|
||||||
|
val vectorHits = vectorIndex.search(qVec, candidateK)
|
||||||
|
|
||||||
|
// Этап 2: загружаем кандидатов из .md (single source of truth)
|
||||||
|
// mapNotNull не умеет suspend, поэтому собираем вручную.
|
||||||
|
val candidates: List<Pair<MemoryNote, Float>> = buildList(vectorHits.size) {
|
||||||
|
for (hit in vectorHits) {
|
||||||
|
val note = mdStore.get(hit.id) ?: continue
|
||||||
|
// Применяем категорийный/конво-фильтр ДО rerank — экономим keywordScore.
|
||||||
|
if (!noteMatches(note, query.category, query.conversationId)) continue
|
||||||
|
add(note to hit.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Этап 3: keyword rerank (vector 0.7 + keyword 0.3)
|
||||||
|
val rescored = candidates.map { (note, vecScore) ->
|
||||||
|
val kwScore = MdMemoryFormat.keywordScore(query.query, note)
|
||||||
|
val finalScore = vecScore * VECTOR_WEIGHT + kwScore * KEYWORD_WEIGHT
|
||||||
|
MemorySearchResult(note, finalScore)
|
||||||
|
}.sortedByDescending { it.score }
|
||||||
|
|
||||||
|
return if (rescored.size > query.topK) rescored.subList(0, query.topK) else rescored
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MemoryStore impl: read-only delegation ───────────────────────
|
||||||
|
|
||||||
|
override suspend fun get(id: String): MemoryNote? = mdStore.get(id)
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
category: pw.binom.agentik.memory.MemoryCategory?,
|
||||||
|
conversationId: String?,
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<MemoryNote> = mdStore.list(category, conversationId, limit, offset)
|
||||||
|
|
||||||
|
override suspend fun markUsed(id: String, at: Instant) = mdStore.markUsed(id, at)
|
||||||
|
|
||||||
|
override suspend fun archiveStale(
|
||||||
|
maxAge: kotlin.time.Duration,
|
||||||
|
maxUseCount: Int,
|
||||||
|
now: Instant,
|
||||||
|
): Int {
|
||||||
|
// Вектор-кэш не хранит lastUsedAt/useCount (только content_hash).
|
||||||
|
// Делегируем в mdStore — он сам знает что удалять; vector удалится
|
||||||
|
// каскадно при archiveStale → delete loop ниже.
|
||||||
|
val deleted = mdStore.archiveStale(maxAge, maxUseCount, now)
|
||||||
|
// Дополнительно чистим vector-кэш от записей, которых больше нет в .md
|
||||||
|
val remaining = mdStore.list(limit = Int.MAX_VALUE).map { it.id }.toSet()
|
||||||
|
vectorIndex.allMeta().forEach { entry ->
|
||||||
|
if (entry.id !in remaining) vectorIndex.remove(entry.id)
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(): Flow<MemoryStoreEvent> = mdStore.events()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { vectorIndex.close() }
|
||||||
|
runCatching { mdStore.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VECTOR_WEIGHT: Float = 0.7f
|
||||||
|
const val KEYWORD_WEIGHT: Float = 0.3f
|
||||||
|
const val VECTOR_CANDIDATES: Int = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.agentik.memory.md.openMdMemory
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть гибридное хранилище памяти (`.md` + sqlite vector index).
|
||||||
|
*
|
||||||
|
* Создаёт:
|
||||||
|
* - [MdMemoryStore] на [memoryRoot] (`.md` файлы);
|
||||||
|
* - [KsqliteVectorIndex] на [vectorDbPath] (sqlite-vec vec0);
|
||||||
|
* - [HybridMdVectorStore] — обёртка с reconcile и hybrid search.
|
||||||
|
*
|
||||||
|
* Перед возвратом выполняет [HybridMdVectorStore.reconcile] — для свежей
|
||||||
|
* БД это приведёт к первичному embed'у всех `.md` файлов; для существующей —
|
||||||
|
* к re-embed'у изменившихся заметок и orphan-cleanup.
|
||||||
|
*
|
||||||
|
* @param memoryRoot директория с `.md` файлами (`USER.md`, `WORLD.md`, ...).
|
||||||
|
* @param vectorDbPath путь к файлу sqlite-БД для vector-кэша.
|
||||||
|
* @param dimension размерность embeddings от [embedder]. Фиксируется
|
||||||
|
* при создании индекса; дальнейшая смена = wipe БД.
|
||||||
|
* @param embedder провайдер embeddings.
|
||||||
|
* @param runReconcile выполнить [HybridMdVectorStore.reconcile] сразу после
|
||||||
|
* открытия. В тестах можно отключить для скорости.
|
||||||
|
*/
|
||||||
|
fun openHybridMemoryStore(
|
||||||
|
memoryRoot: Path,
|
||||||
|
vectorDbPath: Path,
|
||||||
|
dimension: Int,
|
||||||
|
embedder: TextEmbeddingExecutor,
|
||||||
|
runReconcile: Boolean = true,
|
||||||
|
): HybridMdVectorStore {
|
||||||
|
val md = openMdMemory(memoryRoot)
|
||||||
|
val conn = SQLiteConnection.open(vectorDbPath.toString())
|
||||||
|
Schema.migrate(conn, dimension)
|
||||||
|
val idx = KsqliteVectorIndex(conn, dimension)
|
||||||
|
val hybrid = HybridMdVectorStore(md, idx, embedder)
|
||||||
|
if (runReconcile) {
|
||||||
|
kotlinx.coroutines.runBlocking { hybrid.reconcile() }
|
||||||
|
}
|
||||||
|
return hybrid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory вариант для тестов: vector-кэш в `:memory:` sqlite,
|
||||||
|
* `.md` — в `/tmp/agentik-hybrid-test-{random}`.
|
||||||
|
*
|
||||||
|
* Используется POSIX-путь `/tmp`, потому что [System.getenv] / [System.getProperty]
|
||||||
|
* недоступны в KMP commonMain (только JVM). На Windows mingwX64 этот вызов
|
||||||
|
* упадёт — там тесты пока не предполагаются, нативные тесты только linuxX64.
|
||||||
|
* Под JVM `/tmp` либо есть как symlink (Linux/macOS), либо стоит использовать
|
||||||
|
* jvmTest-специфичный factory.
|
||||||
|
*/
|
||||||
|
fun openInMemoryHybridMemoryStore(
|
||||||
|
dimension: Int,
|
||||||
|
embedder: TextEmbeddingExecutor,
|
||||||
|
): HybridMdVectorStore {
|
||||||
|
val tmpDir = Path("/tmp/agentik-hybrid-test-${kotlin.random.Random.nextLong()}")
|
||||||
|
val md = openMdMemory(tmpDir)
|
||||||
|
val conn = SQLiteConnection.memory("hybrid-${kotlin.random.Random.nextLong()}")
|
||||||
|
Schema.migrate(conn, dimension)
|
||||||
|
val idx = KsqliteVectorIndex(conn, dimension)
|
||||||
|
return HybridMdVectorStore(md, idx, embedder)
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemorySystem
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.agentik.memory.md.KeywordMdPrefetcher
|
||||||
|
import pw.binom.agentik.memory.md.KeywordMdReviewer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Связка [HybridMdVectorStore] + keyword-prefetcher + keyword-reviewer.
|
||||||
|
*
|
||||||
|
* Store делегирует I/O между .md (single source of truth) и sqlite-vector-кэшем;
|
||||||
|
* prefetcher и reviewer работают по .md-данным (через [HybridMdVectorStore]),
|
||||||
|
* так что обе роли видят консистентное состояние.
|
||||||
|
*/
|
||||||
|
class HybridMemorySystem internal constructor(
|
||||||
|
override val store: MemoryStore,
|
||||||
|
override val prefetcher: MemoryPrefetcher,
|
||||||
|
override val reviewer: MemoryReviewer,
|
||||||
|
) : MemorySystem {
|
||||||
|
override fun close() = store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает [HybridMemorySystem] для указанной корневой директории + sqlite-БД.
|
||||||
|
*
|
||||||
|
* Под капотом: [HybridMdVectorStore] (md + vector), keyword-prefetcher из
|
||||||
|
* `:memory-md` (работает по store.api), keyword-reviewer без LLM —
|
||||||
|
* LLM-импл добавляется в `:standalone` поверх.
|
||||||
|
*/
|
||||||
|
fun openHybridMemorySystem(
|
||||||
|
memoryRoot: Path,
|
||||||
|
vectorDbPath: Path,
|
||||||
|
dimension: Int,
|
||||||
|
embedder: TextEmbeddingExecutor,
|
||||||
|
runReconcile: Boolean = true,
|
||||||
|
): HybridMemorySystem {
|
||||||
|
val store = openHybridMemoryStore(memoryRoot, vectorDbPath, dimension, embedder, runReconcile)
|
||||||
|
return HybridMemorySystem(
|
||||||
|
store = store,
|
||||||
|
prefetcher = KeywordMdPrefetcher(store),
|
||||||
|
reviewer = KeywordMdReviewer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemoryVectorIndex
|
||||||
|
import pw.binom.agentik.memory.ScoredVector
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ksqlite-реализация [MemoryVectorIndex] поверх `vec0` virtual table
|
||||||
|
* (sqlite-vec extension, встроен в ksqlite).
|
||||||
|
*
|
||||||
|
* Маппинг id → rowid:
|
||||||
|
* - TEXT `id` (== MemoryNote.id, "mem-...") лежит в [Schema.TABLE_META].
|
||||||
|
* - `vec0` индексирует по `rowid` (INTEGER auto-increment).
|
||||||
|
* - JOIN через `WHERE vec0.rowid = meta.rowid`.
|
||||||
|
*
|
||||||
|
* На каждое [add] с contentHash рядом с вектором пишется meta с
|
||||||
|
* content_hash от [MemoryNote.contentHash]. Это позволяет reconcile'у
|
||||||
|
* в [HybridMdVectorStore] определить "изменилась ли заметка" без re-embed.
|
||||||
|
*
|
||||||
|
* Поиск — `vec0` MATCH (cosine distance, sqlite-vec native). Score
|
||||||
|
* конвертируется из distance (0..2, меньше = ближе) в similarity
|
||||||
|
* (0..1, больше = ближе).
|
||||||
|
*
|
||||||
|
* Конкурентность: write-операции сериализуются [mutex]; read'ы
|
||||||
|
* (`size`/`search`) не блокируют.
|
||||||
|
*/
|
||||||
|
class KsqliteVectorIndex internal constructor(
|
||||||
|
private val conn: SQLiteConnection,
|
||||||
|
override val dimension: Int,
|
||||||
|
) : MemoryVectorIndex {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val insertVec: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"INSERT INTO ${Schema.TABLE_VECTORS}(${Schema.COL_VECTOR}) VALUES (?)"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val lastInsertRowIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT last_insert_rowid()"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val insertMeta: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO ${Schema.TABLE_META}
|
||||||
|
(${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH},
|
||||||
|
${Schema.COL_DIMENSION}, ${Schema.COL_UPDATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val findMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Запрос `... WHERE rowid IN (?, ?, ...)`. Подготавливаем на N=$MAX_INLINE_ROWIDS параметров. */
|
||||||
|
private val findMetaByRowIdsStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
(1..MAX_INLINE_ROWIDS).joinToString(
|
||||||
|
separator = ",",
|
||||||
|
prefix = "SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} IN (",
|
||||||
|
postfix = ")",
|
||||||
|
) { "?" }
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
private val deleteByRowIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_VECTORS} WHERE rowid = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val deleteMetaByRowIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val deleteMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val sizeMetaStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT COUNT(*) FROM ${Schema.TABLE_META}"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val allMetaStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META}"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sqlite-vec требует чтобы `LIMIT` в MATCH-запросе был integer-литералом,
|
||||||
|
* а не `?`. Поэтому для search используем динамическую подготовку
|
||||||
|
* (кешированную по [k]).
|
||||||
|
*
|
||||||
|
* Vec0 KNN check (`sqlite-vec` source): «A LIMIT or 'k = ?' constraint is
|
||||||
|
* required on vec0 knn queries». Имя параметра `:k` тоже поддерживается,
|
||||||
|
* но у ksqlite bind API — только позиционный; literal проще.
|
||||||
|
*/
|
||||||
|
private val searchCache = HashMap<Int, SQLitePreparedStatement>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выдать rowid для существующей записи или -1 если нет.
|
||||||
|
*
|
||||||
|
* **ВАЖНО**: вызывающий ОБЯЗАН держать [mutex]. Этот метод НЕ
|
||||||
|
* reentrant — повторный вход в [Mutex.withLock] приведёт к
|
||||||
|
* deadlock (kotlinx.coroutines.sync.Mutex не reentrant).
|
||||||
|
*/
|
||||||
|
private fun findRowIdLocked(id: String): Long {
|
||||||
|
findMetaByIdStmt.reset()
|
||||||
|
findMetaByIdStmt.clearBindings()
|
||||||
|
findMetaByIdStmt.bindText(1, id)
|
||||||
|
findMetaByIdStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0) ?: -1L
|
||||||
|
}
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun size(): Long = mutex.withLock {
|
||||||
|
sizeMetaStmt.reset()
|
||||||
|
sizeMetaStmt.clearBindings()
|
||||||
|
sizeMetaStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) rs.getLong(0) ?: 0L else 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun add(id: String, embedding: FloatArray) {
|
||||||
|
require(embedding.size == dimension) {
|
||||||
|
"embedding dim=${embedding.size} != index dim=$dimension"
|
||||||
|
}
|
||||||
|
add(id, embedding, contentHash = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить или обновить запись с явным content_hash.
|
||||||
|
* Если запись с таким id уже есть — обновляет и вектор, и meta.
|
||||||
|
* Иначе — создаёт новый rowid.
|
||||||
|
*/
|
||||||
|
suspend fun add(id: String, embedding: FloatArray, contentHash: String) {
|
||||||
|
require(embedding.size == dimension) {
|
||||||
|
"embedding dim=${embedding.size} != index dim=$dimension"
|
||||||
|
}
|
||||||
|
mutex.withLock {
|
||||||
|
val existingRowId = findRowIdLocked(id)
|
||||||
|
val rowId: Long = if (existingRowId > 0) {
|
||||||
|
// Update: заменяем вектор по существующему rowid
|
||||||
|
insertVec.reset()
|
||||||
|
insertVec.clearBindings()
|
||||||
|
insertVec.bindVector(1, embedding)
|
||||||
|
insertVec.executeUpdate()
|
||||||
|
existingRowId
|
||||||
|
} else {
|
||||||
|
// Insert: получаем свежий rowid
|
||||||
|
insertVec.reset()
|
||||||
|
insertVec.clearBindings()
|
||||||
|
insertVec.bindVector(1, embedding)
|
||||||
|
insertVec.executeUpdate()
|
||||||
|
lastInsertRowIdStmt.reset()
|
||||||
|
lastInsertRowIdStmt.clearBindings()
|
||||||
|
lastInsertRowIdStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) rs.getLong(0) ?: error("no last_insert_rowid()") else error("no last_insert_rowid()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertMeta.reset()
|
||||||
|
insertMeta.clearBindings()
|
||||||
|
insertMeta.bindLong(1, rowId)
|
||||||
|
insertMeta.bindText(2, id)
|
||||||
|
insertMeta.bindText(3, contentHash)
|
||||||
|
insertMeta.bindLong(4, dimension.toLong())
|
||||||
|
insertMeta.bindLong(5, Clock.System.now().toEpochMilliseconds())
|
||||||
|
insertMeta.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(id: String): Boolean = mutex.withLock {
|
||||||
|
val rowId = findRowIdLocked(id)
|
||||||
|
if (rowId <= 0) return@withLock false
|
||||||
|
|
||||||
|
// Удаляем meta сначала — иначе orphan-row в vec0.
|
||||||
|
deleteMetaByIdStmt.reset()
|
||||||
|
deleteMetaByIdStmt.clearBindings()
|
||||||
|
deleteMetaByIdStmt.bindText(1, id)
|
||||||
|
deleteMetaByIdStmt.executeUpdate()
|
||||||
|
|
||||||
|
deleteByRowIdStmt.reset()
|
||||||
|
deleteByRowIdStmt.clearBindings()
|
||||||
|
deleteByRowIdStmt.bindLong(1, rowId)
|
||||||
|
deleteByRowIdStmt.executeUpdate()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прочитать content_hash для id. null если записи нет. */
|
||||||
|
suspend fun getContentHash(id: String): String? = mutex.withLock {
|
||||||
|
findMetaByIdStmt.reset()
|
||||||
|
findMetaByIdStmt.clearBindings()
|
||||||
|
findMetaByIdStmt.bindText(1, id)
|
||||||
|
findMetaByIdStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) rs.getText(1) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полный список (rowid, id, contentHash) для reconcile'а. */
|
||||||
|
suspend fun allMeta(): List<MetaEntry> = mutex.withLock {
|
||||||
|
allMetaStmt.reset()
|
||||||
|
allMetaStmt.clearBindings()
|
||||||
|
val out = mutableListOf<MetaEntry>()
|
||||||
|
allMetaStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
val rowId = rs.getLong(0) ?: continue
|
||||||
|
val id = rs.getText(1) ?: continue
|
||||||
|
val hash = rs.getText(2) ?: continue
|
||||||
|
out.add(MetaEntry(rowId, id, hash))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(
|
||||||
|
query: FloatArray,
|
||||||
|
k: Int,
|
||||||
|
filter: (MemoryNote) -> Boolean,
|
||||||
|
): List<ScoredVector> {
|
||||||
|
require(query.size == dimension) {
|
||||||
|
"query dim=${query.size} != index dim=$dimension"
|
||||||
|
}
|
||||||
|
val safeK = k.coerceAtLeast(1)
|
||||||
|
return mutex.withLock {
|
||||||
|
// sqlite-vec MATCH требует минимальный запрос без JOIN/лишних
|
||||||
|
// ORDER BY — иначе "A LIMIT or 'k = ?' constraint is required".
|
||||||
|
// Поэтому делаем два запроса:
|
||||||
|
// 1) vec0 ANN → (rowid, distance)
|
||||||
|
// 2) meta lookup по собранным rowid → id
|
||||||
|
val stmt = searchCache.getOrPut(safeK) {
|
||||||
|
conn.prepare(
|
||||||
|
"""
|
||||||
|
SELECT rowid, distance
|
||||||
|
FROM ${Schema.TABLE_VECTORS}
|
||||||
|
WHERE ${Schema.COL_VECTOR} MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT $safeK
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stmt.reset()
|
||||||
|
stmt.clearBindings()
|
||||||
|
stmt.bindVector(1, query)
|
||||||
|
|
||||||
|
val candidates = mutableListOf<Pair<Long, Float>>()
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
val rowId = rs.getLong(0) ?: continue
|
||||||
|
val distance = rs.getDouble(1) ?: continue
|
||||||
|
val score = ((1.0 - distance / 2.0) * 1.0).toFloat().coerceIn(0f, 1f)
|
||||||
|
candidates.add(rowId to score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) return@withLock emptyList<ScoredVector>()
|
||||||
|
|
||||||
|
// 2-й запрос: meta по списку rowid.
|
||||||
|
val rowIds = candidates.map { it.first }
|
||||||
|
val idByRowId = HashMap<Long, String>(candidates.size)
|
||||||
|
findMetaByRowIdsStmt.reset()
|
||||||
|
findMetaByRowIdsStmt.clearBindings()
|
||||||
|
for ((idx, rowId) in rowIds.withIndex()) {
|
||||||
|
findMetaByRowIdsStmt.bindLong(idx + 1, rowId)
|
||||||
|
}
|
||||||
|
findMetaByRowIdsStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
val rowId = rs.getLong(0) ?: continue
|
||||||
|
val id = rs.getText(1) ?: continue
|
||||||
|
idByRowId[rowId] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.mapNotNull { (rowId, score) ->
|
||||||
|
idByRowId[rowId]?.let { ScoredVector(it, score) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
// ksqlite + WAL — flush не нужен. Метод для совместимости с интерфейсом.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
insertVec.close()
|
||||||
|
lastInsertRowIdStmt.close()
|
||||||
|
insertMeta.close()
|
||||||
|
findMetaByIdStmt.close()
|
||||||
|
deleteByRowIdStmt.close()
|
||||||
|
deleteMetaByRowIdStmt.close()
|
||||||
|
deleteMetaByIdStmt.close()
|
||||||
|
sizeMetaStmt.close()
|
||||||
|
allMetaStmt.close()
|
||||||
|
searchCache.values.forEach { it.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MetaEntry(val rowId: Long, val id: String, val contentHash: String)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Максимум rowid, которые мы зашиваем в `IN (?,?,...)` одним prepared statement'ом. */
|
||||||
|
const val MAX_INLINE_ROWIDS = 256
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL/DML для ksqlite-бэкенда `:memory-md-vector`.
|
||||||
|
*
|
||||||
|
* Две таблицы:
|
||||||
|
*
|
||||||
|
* * `memory_vectors` (vec0) — ANN-индекс. Содержит embedding + первичный
|
||||||
|
* ключ `rowid` (auto-increment INTEGER, sqlite-vec требует именно его).
|
||||||
|
* Размерность задаётся `float[DIMENSION]` при создании.
|
||||||
|
*
|
||||||
|
* * `memory_meta` — рядом с вектором: TEXT `id` (== MemoryNote.id) +
|
||||||
|
* `rowid` (тот же, что в vec0) + `content_hash` (от MemoryNote.contentHash()).
|
||||||
|
* Используется reconcile'ом — если хэш в meta не совпадает с тем, что
|
||||||
|
* вычисляется из текущего `.md` файла → re-embed.
|
||||||
|
*
|
||||||
|
* JOIN между vec0 и meta: `WHERE vec0.rowid = meta.rowid`.
|
||||||
|
*
|
||||||
|
* Миграция через `PRAGMA user_version` (как в `:journal-ksqlite/Schema.kt`).
|
||||||
|
*/
|
||||||
|
internal object Schema {
|
||||||
|
|
||||||
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
|
const val TABLE_VECTORS = "memory_vectors"
|
||||||
|
const val TABLE_META = "memory_meta"
|
||||||
|
|
||||||
|
const val COL_ID = "id"
|
||||||
|
const val COL_ROWID = "rowid"
|
||||||
|
const val COL_VECTOR = "embedding"
|
||||||
|
const val COL_HASH = "content_hash"
|
||||||
|
const val COL_DIMENSION = "dimension"
|
||||||
|
const val COL_UPDATED_AT = "updated_at"
|
||||||
|
|
||||||
|
fun v1Ddl(dimension: Int): String = """
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS $TABLE_VECTORS USING vec0(
|
||||||
|
$COL_VECTOR float[$dimension]
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS $TABLE_META (
|
||||||
|
$COL_ROWID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
$COL_ID TEXT NOT NULL UNIQUE,
|
||||||
|
$COL_HASH TEXT NOT NULL,
|
||||||
|
$COL_DIMENSION INTEGER NOT NULL,
|
||||||
|
$COL_UPDATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meta_id ON $TABLE_META($COL_ID);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
fun migrate(conn: SQLiteConnection, dimension: Int) {
|
||||||
|
val current = readUserVersion(conn)
|
||||||
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
|
conn.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
if (current < 1) {
|
||||||
|
conn.exec(v1Ddl(dimension))
|
||||||
|
}
|
||||||
|
writeUserVersion(conn, CURRENT_VERSION)
|
||||||
|
conn.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||||
|
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
||||||
|
conn.exec("PRAGMA user_version = $version")
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.runBlocking as kRunBlocking
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты гибридного стора: делегирование в .md, vector ANN, reconcile,
|
||||||
|
* hybrid search rerank.
|
||||||
|
*
|
||||||
|
* Используется [kRunBlocking] (а не `runTest`) потому что `MdMemoryStore`
|
||||||
|
* делает реальный файловый I/O (`kotlinx-io`), который плохо дружит с
|
||||||
|
* TestDispatcher'ом — `runTest` зависает на virtual-time I/O.
|
||||||
|
*/
|
||||||
|
class HybridMdVectorStoreTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детерминированный [TextEmbeddingExtractor] для тестов модуля.
|
||||||
|
* Хеширует текст в псевдо-вектор фиксированной размерности, L2-normalize.
|
||||||
|
* Заворачивается в [TextEmbeddingExecutor] с пред-объявленной размерностью.
|
||||||
|
*/
|
||||||
|
private fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor =
|
||||||
|
TextEmbeddingExecutor(FakeTestExtractor(dimension), knownDimension = dimension)
|
||||||
|
|
||||||
|
private class FakeTestExtractor(val dim: Int) : TextEmbeddingExtractor {
|
||||||
|
override fun embed(text: String): TextEmbedding {
|
||||||
|
val v = FloatArray(dim)
|
||||||
|
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
||||||
|
for (i in 0 until dim) {
|
||||||
|
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||||
|
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||||
|
}
|
||||||
|
var norm = 0f
|
||||||
|
for (x in v) norm += x * x
|
||||||
|
norm = kotlin.math.sqrt(norm)
|
||||||
|
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||||
|
return TextEmbedding(v)
|
||||||
|
}
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun note(
|
||||||
|
id: String,
|
||||||
|
content: String,
|
||||||
|
category: MemoryCategory = MemoryCategory.USER,
|
||||||
|
source: MemorySource = MemorySource.USER_EXPLICIT,
|
||||||
|
) = MemoryNote(
|
||||||
|
id = id,
|
||||||
|
category = category,
|
||||||
|
content = content,
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||||
|
lastUsedAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||||
|
useCount = 0,
|
||||||
|
conversationId = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upsertWritesToBothMdAndVectorCache() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-1", "user prefers dark mode"))
|
||||||
|
|
||||||
|
val fromMd = store.get("mem-1")
|
||||||
|
assertNotNull(fromMd)
|
||||||
|
assertEquals("user prefers dark mode", fromMd.content)
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "user prefers dark mode", topK = 5))
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
assertEquals("mem-1", hits.first().note.id)
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesFromBothLayers() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-2", "lives in Saint Petersburg"))
|
||||||
|
store.upsert(note("mem-3", "loves Kotlin multiplatform"))
|
||||||
|
|
||||||
|
assertEquals(2, store.list(limit = 10).size)
|
||||||
|
|
||||||
|
val removed = store.delete("mem-2")
|
||||||
|
assertTrue(removed)
|
||||||
|
|
||||||
|
assertEquals(1, store.list(limit = 10).size)
|
||||||
|
assertNull(store.get("mem-2"))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "Saint Petersburg", topK = 5))
|
||||||
|
assertTrue(hits.isEmpty() || hits.all { it.note.id != "mem-2" })
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconcileOnEmptyStoreIsNoOp() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
val report = store.reconcile()
|
||||||
|
assertEquals(0, report.added)
|
||||||
|
assertEquals(0, report.reembedded)
|
||||||
|
assertEquals(0, report.orphansRemoved)
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconcileIsIdempotent() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-10", "works at Binom"))
|
||||||
|
val report = store.reconcile()
|
||||||
|
assertEquals(0, report.totalChanged, "идемпотентность reconcile: повторный вызов no-op")
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchReturnsRelevantResultsByKeyword() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-a", "kotlin multiplatform"))
|
||||||
|
store.upsert(note("mem-b", "java enterprise"))
|
||||||
|
store.upsert(note("mem-c", "kotlin coroutines"))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 5))
|
||||||
|
val ids = hits.map { it.note.id }.toSet()
|
||||||
|
assertTrue("mem-a" in ids, "expected 'kotlin multiplatform' in results: $ids")
|
||||||
|
assertTrue("mem-c" in ids, "expected 'kotlin coroutines' in results: $ids")
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchRespectsCategoryFilter() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("user-1", "kotlin lover", MemoryCategory.USER))
|
||||||
|
store.upsert(note("world-1", "kotlin 2.0 released", MemoryCategory.WORLD))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 10, category = MemoryCategory.USER))
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
assertEquals("user-1", hits.first().note.id)
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hybridScoreCombinesVectorAndKeyword() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-x", "kotlin multiplatform project"))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "kotlin multiplatform", topK = 1))
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
val score = hits.first().score
|
||||||
|
assertTrue(score in 0f..1f, "score $score out of range")
|
||||||
|
assertTrue(score > 0.5f, "expected hybrid score > 0.5, got $score")
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
|
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
|
||||||
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не
|
// не подключаются. Раньше был нужен потому что text-embedding-kmp был
|
||||||
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
|
// только в локальном ~/.m2; с 2026-09-21 (v4 в caffeine) можно убрать,
|
||||||
|
// но оставлен на случай если CI внезапно отвалится от Nexus.
|
||||||
// Использование:
|
// Использование:
|
||||||
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
||||||
// Локальная разработка без флага — зависимости подключаются как обычно.
|
// Локальная разработка без флага — зависимости подключаются как обычно.
|
||||||
@@ -25,6 +26,10 @@ kotlin {
|
|||||||
api(project(":memory-api"))
|
api(project(":memory-api"))
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
// `pw.binom.ai.embeddingtext:api` (TextEmbeddingExtractor + TextEmbedding)
|
||||||
|
// теперь KMP с нативом (linuxX64/mingwX64/macOS/ios); тянем в commonMain.
|
||||||
|
// Реализации (`siglip`, `http`) — JVM+Android only, см. jvmMain ниже.
|
||||||
|
api(libs.text.embedding.api)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
@@ -34,14 +39,11 @@ kotlin {
|
|||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(libs.jvector)
|
implementation(libs.jvector)
|
||||||
implementation(libs.sqldelight.sqlite.driver)
|
implementation(libs.sqldelight.sqlite.driver)
|
||||||
|
// Конкретная реализация TextEmbeddingExtractor поверх ONNX.
|
||||||
|
implementation(libs.text.embedding.siglip)
|
||||||
}
|
}
|
||||||
jvmTest.dependencies {
|
jvmTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
|
||||||
add("jvmMainApi", libs.text.embedding.api)
|
|
||||||
add("jvmMainImplementation", libs.text.embedding.siglip)
|
|
||||||
}
|
|
||||||
|
|||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
package pw.binom.agentik.memory.vector
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Провайдер эмбеддингов: превращает текст в FloatArray фиксированной размерности.
|
|
||||||
*
|
|
||||||
* Реализация по умолчанию — HTTP-вызов `POST /v1/embeddings` к OpenAI-совместимому
|
|
||||||
* API (OpenAI / litellm-proxy / vllm). С LRU-кэшом, чтобы не ходить в сеть
|
|
||||||
* на каждый search/upsert.
|
|
||||||
*/
|
|
||||||
interface EmbeddingProvider {
|
|
||||||
val dimension: Int
|
|
||||||
suspend fun embed(text: String): FloatArray
|
|
||||||
|
|
||||||
/** Batch-вариант. По умолчанию — последовательный вызов [embed]. */
|
|
||||||
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
|
||||||
texts.map { embed(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Детерминированный провайдер для тестов: хеширует текст в псевдо-вектор.
|
|
||||||
* Используется только в commonTest; в продакшн заменяется на HttpEmbeddingProvider.
|
|
||||||
*/
|
|
||||||
class FakeEmbeddingProvider(override val dimension: Int = 32) : EmbeddingProvider {
|
|
||||||
override suspend fun embed(text: String): FloatArray {
|
|
||||||
val v = FloatArray(dimension)
|
|
||||||
// Простейший детерминированный seed — сумма char'ов по модулю.
|
|
||||||
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
|
||||||
for (i in 0 until dimension) {
|
|
||||||
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
|
||||||
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
|
||||||
}
|
|
||||||
// L2-normalize чтобы cosine работал осмысленно.
|
|
||||||
var norm = 0f
|
|
||||||
for (x in v) norm += x * x
|
|
||||||
norm = kotlin.math.sqrt(norm)
|
|
||||||
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+26
-55
@@ -1,63 +1,34 @@
|
|||||||
package pw.binom.agentik.memory.vector
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
import pw.binom.agentik.memory.MemoryVectorIndex as KmpMemoryVectorIndex
|
||||||
import pw.binom.agentik.memory.MemoryNote
|
import pw.binom.agentik.memory.ScoredVector as KmpScoredVector
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1].
|
* JVM-only alias на KMP-контракт из `:memory-api`. Удалять нельзя — пока
|
||||||
* Чем ближе к 1.0, тем семантически ближе query к заметке.
|
* `:memory-vector` существует как JVM-only модуль с JVector-имплементацией,
|
||||||
*/
|
* все его internal helper'ы продолжают импортировать `MemoryVectorIndex` из
|
||||||
data class ScoredVector(
|
* `pw.binom.agentik.memory.vector.*` (старое FQN). После удаления модуля —
|
||||||
val id: String,
|
* можно убрать этот файл и переименовать пакеты импортов.
|
||||||
val score: Float,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
|
||||||
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
|
|
||||||
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
|
|
||||||
*
|
*
|
||||||
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
* Раньше жил прямо здесь (`MemoryVectorIndex` + `ScoredVector` в
|
||||||
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это
|
* `:memory-vector/commonMain`), но переехал в `:memory-api` 2026-09-21
|
||||||
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
* чтобы стать доступным из KMP-модуля `:memory-md-vector`.
|
||||||
*/
|
*/
|
||||||
interface MemoryVectorIndex : AutoCloseable {
|
|
||||||
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
|
||||||
val dimension: Int
|
|
||||||
|
|
||||||
/** Количество записей в индексе. */
|
@Deprecated(
|
||||||
suspend fun size(): Long
|
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||||
|
replaceWith = ReplaceWith(
|
||||||
|
"MemoryVectorIndex",
|
||||||
|
"pw.binom.agentik.memory.MemoryVectorIndex",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
typealias MemoryVectorIndex = KmpMemoryVectorIndex
|
||||||
|
|
||||||
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
|
@Deprecated(
|
||||||
suspend fun add(id: String, embedding: FloatArray)
|
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||||
|
replaceWith = ReplaceWith(
|
||||||
/** Удалить запись по [id]. Возвращает true если запись была. */
|
"ScoredVector",
|
||||||
suspend fun remove(id: String): Boolean
|
"pw.binom.agentik.memory.ScoredVector",
|
||||||
|
),
|
||||||
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id (например, по категории). */
|
)
|
||||||
suspend fun search(
|
typealias ScoredVector = KmpScoredVector
|
||||||
query: FloatArray,
|
|
||||||
k: Int,
|
|
||||||
filter: (MemoryNote) -> Boolean = { true },
|
|
||||||
): List<ScoredVector>
|
|
||||||
|
|
||||||
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
|
|
||||||
suspend fun flush()
|
|
||||||
|
|
||||||
override fun close()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Доп. контекст для vector-индекса: фильтр по категории и conversationId
|
|
||||||
* передаётся через замыкание, которое получает [MemoryNote]. Так [MemoryStore]
|
|
||||||
* остаётся единственным источником правды по метаданным.
|
|
||||||
*/
|
|
||||||
fun noteMatches(
|
|
||||||
note: MemoryNote,
|
|
||||||
category: MemoryCategory? = null,
|
|
||||||
conversationId: String? = null,
|
|
||||||
): Boolean {
|
|
||||||
if (category != null && note.category != category) return false
|
|
||||||
if (conversationId != null && note.conversationId != conversationId) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
+8
-6
@@ -6,6 +6,8 @@ import pw.binom.agentik.memory.MemorySearchQuery
|
|||||||
import pw.binom.agentik.memory.MemorySearchResult
|
import pw.binom.agentik.memory.MemorySearchResult
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.agentik.memory.noteMatches
|
||||||
import kotlin.math.exp
|
import kotlin.math.exp
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -23,13 +25,13 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
|
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
|
||||||
* потом re-rank по recency.
|
* потом re-rank по recency.
|
||||||
*
|
*
|
||||||
* [embeddingProvider] обязателен — используется для эмбеддинга контента при
|
* [embedding] обязателен — используется для эмбеддинга контента при
|
||||||
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
|
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
|
||||||
*/
|
*/
|
||||||
class VectorMemoryStore(
|
class VectorMemoryStore(
|
||||||
private val index: MemoryVectorIndex,
|
private val index: MemoryVectorIndex,
|
||||||
private val metaStore: MemoryMetaStore,
|
private val metaStore: MemoryMetaStore,
|
||||||
private val embeddingProvider: EmbeddingProvider,
|
private val embedding: TextEmbeddingExecutor,
|
||||||
) : MemoryStore {
|
) : MemoryStore {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
@@ -37,9 +39,9 @@ class VectorMemoryStore(
|
|||||||
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
|
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
|
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
|
||||||
val embedding = embeddingProvider.embed(note.content)
|
val vec = embedding.embed(note.content)
|
||||||
metaStore.put(note, embedding)
|
metaStore.put(note, vec)
|
||||||
index.add(note.id, embedding)
|
index.add(note.id, vec)
|
||||||
_events.emit(MemoryStoreEvent.Upserted(note))
|
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ class VectorMemoryStore(
|
|||||||
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
|
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
|
||||||
|
|
||||||
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
|
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
|
||||||
val queryEmbedding = embeddingProvider.embed(query.query)
|
val queryEmbedding = embedding.embed(query.query)
|
||||||
val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
|
val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
|
||||||
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
|
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
|
||||||
// через [metaStore.get] + [noteMatches] отрежет лишних.
|
// через [metaStore.get] + [noteMatches] отрежет лишних.
|
||||||
|
|||||||
+7
-5
@@ -9,6 +9,7 @@ import pw.binom.agentik.memory.MemorySearchQuery
|
|||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.memory.MemorySystem
|
import pw.binom.agentik.memory.MemorySystem
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Бандл компонентов vector-бэкенда памяти — то же, что
|
* Бандл компонентов vector-бэкенда памяти — то же, что
|
||||||
@@ -36,19 +37,20 @@ class VectorMemorySystem(
|
|||||||
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
|
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
|
||||||
*
|
*
|
||||||
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
|
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
|
||||||
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient
|
* @param embedding [TextEmbeddingExecutor] — обычно HttpEmbeddingClient.asExecutor()
|
||||||
* @param topK размер top-K для prefetch
|
* @param topK размер top-K для prefetch
|
||||||
*/
|
*/
|
||||||
fun open(
|
fun open(
|
||||||
dbPath: String,
|
dbPath: String,
|
||||||
embedding: EmbeddingProvider,
|
embedding: TextEmbeddingExecutor,
|
||||||
topK: Int = 10,
|
topK: Int = 10,
|
||||||
): VectorMemorySystem {
|
): VectorMemorySystem {
|
||||||
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension)
|
val dim = embedding.dimension
|
||||||
|
val metaStore = SqliteMemoryMetaStore.open(dbPath, dim)
|
||||||
// Граф пересобирается из SQLite (источник правды): без seed'ов
|
// Граф пересобирается из SQLite (источник правды): без seed'ов
|
||||||
// после рестарта in-RAM индекс пуст и search возвращал бы [],
|
// после рестарта in-RAM индекс пуст и search возвращал бы [],
|
||||||
// пока не появятся новые upsert'ы.
|
// пока не появятся новые upsert'ы.
|
||||||
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries())
|
val index = JVectorMemoryIndex(dim, metaStore.allEntries())
|
||||||
val store = VectorMemoryStore(index, metaStore, embedding)
|
val store = VectorMemoryStore(index, metaStore, embedding)
|
||||||
val prefetcher = VectorPrefetcher(store, topK)
|
val prefetcher = VectorPrefetcher(store, topK)
|
||||||
val reviewer = VectorMemoryReviewer(store)
|
val reviewer = VectorMemoryReviewer(store)
|
||||||
@@ -59,7 +61,7 @@ class VectorMemorySystem(
|
|||||||
closables = listOfNotNull(
|
closables = listOfNotNull(
|
||||||
metaStore,
|
metaStore,
|
||||||
index,
|
index,
|
||||||
embedding as? AutoCloseable,
|
embedding,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -5,25 +5,26 @@ import java.net.http.HttpClient
|
|||||||
import java.net.http.HttpRequest
|
import java.net.http.HttpRequest
|
||||||
import java.net.http.HttpResponse
|
import java.net.http.HttpResponse
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
|
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
|
||||||
* Используется при memory-backend=vector.
|
* Используется при memory-backend=vector.
|
||||||
*
|
*
|
||||||
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов
|
* Реализует [TextEmbeddingExtractor] (из text-embedding-kmp:api) + оборачивается
|
||||||
* к API на одинаковых промптах.
|
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||||
|
* VectorMemoryStore. LRU-кэш на [cacheSize] текстов (default 256) — дедупликация
|
||||||
|
* запросов к API на одинаковых промптах.
|
||||||
*
|
*
|
||||||
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
|
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
|
||||||
* @param apiKey bearer-токен
|
* @param apiKey bearer-токен
|
||||||
@@ -35,9 +36,9 @@ class HttpEmbeddingClient(
|
|||||||
private val apiUrl: String,
|
private val apiUrl: String,
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
private val model: String,
|
private val model: String,
|
||||||
override val dimension: Int,
|
private val dimension: Int,
|
||||||
cacheSize: Int = 256,
|
cacheSize: Int = 256,
|
||||||
) : EmbeddingProvider, AutoCloseable {
|
) : TextEmbeddingExtractor {
|
||||||
|
|
||||||
private val cache = LruCache<String, FloatArray>(cacheSize)
|
private val cache = LruCache<String, FloatArray>(cacheSize)
|
||||||
private val http: HttpClient = HttpClient.newBuilder()
|
private val http: HttpClient = HttpClient.newBuilder()
|
||||||
@@ -45,11 +46,11 @@ class HttpEmbeddingClient(
|
|||||||
.build()
|
.build()
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
override suspend fun embed(text: String): FloatArray {
|
override fun embed(text: String): TextEmbedding {
|
||||||
cache.get(text)?.let { return it }
|
cache.get(text)?.let { return TextEmbedding(it) }
|
||||||
val vector = fetchEmbedding(text)
|
val vector = fetchEmbedding(text)
|
||||||
cache.put(text, vector)
|
cache.put(text, vector)
|
||||||
return vector
|
return TextEmbedding(vector)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchEmbedding(text: String): FloatArray {
|
private fun fetchEmbedding(text: String): FloatArray {
|
||||||
@@ -83,6 +84,9 @@ class HttpEmbeddingClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun close() = http.close()
|
override fun close() = http.close()
|
||||||
|
|
||||||
|
/** Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью. */
|
||||||
|
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = dimension)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LruCache<K, V>(private val capacity: Int) {
|
private class LruCache<K, V>(private val capacity: Int) {
|
||||||
|
|||||||
+22
-26
@@ -1,25 +1,22 @@
|
|||||||
package pw.binom.agentik.memory.vector.embedding
|
package pw.binom.agentik.memory.vector.embedding
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
import kotlinx.coroutines.sync.withLock
|
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
|
||||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
|
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
|
||||||
*
|
*
|
||||||
* Особенности:
|
* Реализует [TextEmbeddingExtractor] напрямую (делегирует в
|
||||||
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU),
|
* `createSiglip2TextExtractor` из text-embedding-kmp:siglip) + оборачивается
|
||||||
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать
|
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||||
* доступ из нескольких корутин (ONNX-сессия не reentrant).
|
* VectorMemoryStore. Сиглизация через `Dispatchers.IO` теперь внутри
|
||||||
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр
|
* `TextEmbeddingExecutor.embed` — раньше лежала здесь.
|
||||||
* `dimension` в конструкторе не принимаем — берём через [probeDimension].
|
*
|
||||||
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на
|
* Размерность фиксирована extractor'ом (SigLIP2-base = 768); передаём
|
||||||
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется —
|
* явно в [asExecutor].
|
||||||
* легко добавить.
|
|
||||||
*
|
*
|
||||||
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
|
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
|
||||||
* Скачать: см. README репы `text-embedding-kmp`.
|
* Скачать: см. README репы `text-embedding-kmp`.
|
||||||
@@ -27,23 +24,22 @@ import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
|||||||
class SiglipEmbeddingProvider(
|
class SiglipEmbeddingProvider(
|
||||||
modelPath: String,
|
modelPath: String,
|
||||||
tokenizerPath: String,
|
tokenizerPath: String,
|
||||||
) : EmbeddingProvider, AutoCloseable {
|
) : TextEmbeddingExtractor {
|
||||||
|
|
||||||
private val extractor: TextEmbeddingExtractor =
|
private val delegate: TextEmbeddingExtractor =
|
||||||
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
||||||
|
|
||||||
override val dimension: Int = run {
|
override fun embed(text: String): TextEmbedding = delegate.embed(text)
|
||||||
val probe = extractor.embed("probe")
|
|
||||||
probe.dim
|
|
||||||
}
|
|
||||||
|
|
||||||
private val mutex = Mutex()
|
override fun close() = delegate.close()
|
||||||
|
|
||||||
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) {
|
/**
|
||||||
mutex.withLock { extractor.embed(text).values }
|
* Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью 768
|
||||||
}
|
* (SigLIP2-base). Сигнатура стабильна — extractor всегда возвращает 768-dim.
|
||||||
|
*/
|
||||||
|
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = SIGLIP2_DIM)
|
||||||
|
|
||||||
override fun close() {
|
companion object {
|
||||||
extractor.close()
|
const val SIGLIP2_DIM: Int = 768
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детерминированный [TextEmbeddingExtractor] для тестов: хеширует текст в
|
||||||
|
* псевдо-вектор фиксированной размерности. L2-normalize чтобы cosine
|
||||||
|
* работал осмысленно.
|
||||||
|
*
|
||||||
|
* НЕ suspend, как и положено extractor'у — suspend-обёртка живёт в
|
||||||
|
* [TextEmbeddingExecutor] (используется в VectorMemoryStore через
|
||||||
|
* [fakeExecutor]).
|
||||||
|
*/
|
||||||
|
class FakeTextEmbeddingExtractor(
|
||||||
|
val dimension: Int = 32,
|
||||||
|
) : TextEmbeddingExtractor {
|
||||||
|
override fun embed(text: String): TextEmbedding {
|
||||||
|
val v = FloatArray(dimension)
|
||||||
|
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
||||||
|
for (i in 0 until dimension) {
|
||||||
|
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||||
|
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||||
|
}
|
||||||
|
var norm = 0f
|
||||||
|
for (x in v) norm += x * x
|
||||||
|
norm = kotlin.math.sqrt(norm)
|
||||||
|
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||||
|
return TextEmbedding(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удобная обёртка для тестов: создаёт `FakeTextEmbeddingExtractor` и
|
||||||
|
* сразу заворачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью.
|
||||||
|
*/
|
||||||
|
fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor =
|
||||||
|
TextEmbeddingExecutor(FakeTextEmbeddingExtractor(dimension), knownDimension = dimension)
|
||||||
+4
-4
@@ -32,7 +32,7 @@ class VectorMemoryStoreTest {
|
|||||||
// Загружаем начальные entries из metaStore (на случай если что-то там есть).
|
// Загружаем начальные entries из metaStore (на случай если что-то там есть).
|
||||||
val seedEntries = metaStore.allEntries()
|
val seedEntries = metaStore.allEntries()
|
||||||
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||||
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim))
|
store = VectorMemoryStore(index, metaStore, fakeEmbeddingExecutor(dimension = dim))
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterTest
|
@AfterTest
|
||||||
@@ -127,7 +127,7 @@ class VectorMemoryStoreTest {
|
|||||||
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
||||||
val seedEntries = meta2.allEntries()
|
val seedEntries = meta2.allEntries()
|
||||||
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||||
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim))
|
val store2 = VectorMemoryStore(idx2, meta2, fakeEmbeddingExecutor(dimension = dim))
|
||||||
try {
|
try {
|
||||||
assertEquals(2L, idx2.size())
|
assertEquals(2L, idx2.size())
|
||||||
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
|
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
|
||||||
@@ -141,11 +141,11 @@ class VectorMemoryStoreTest {
|
|||||||
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
|
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
|
||||||
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
|
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
|
||||||
// из SQLite — иначе после рестарта search возвращает [] до первого upsert.
|
// из SQLite — иначе после рестарта search возвращает [] до первого upsert.
|
||||||
val first = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
val first = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
|
||||||
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
|
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
|
||||||
first.close()
|
first.close()
|
||||||
|
|
||||||
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
val second = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
|
||||||
try {
|
try {
|
||||||
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
|
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
|
||||||
assertTrue(results.any { it.note.id == "r" })
|
assertTrue(results.any { it.note.id == "r" })
|
||||||
|
|||||||
+11
-5
@@ -17,17 +17,18 @@ import kotlin.test.assertTrue
|
|||||||
class SiglipEmbeddingProviderTest {
|
class SiglipEmbeddingProviderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dimension is 768 when model loads successfully`() {
|
fun `dimension is 768 when model loads successfully`() = runBlocking {
|
||||||
val modelDir = File("/tmp/text-emb-model")
|
val modelDir = File("/tmp/text-emb-model")
|
||||||
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
|
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
|
||||||
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
|
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
|
||||||
}
|
}
|
||||||
SiglipEmbeddingProvider(
|
val provider = SiglipEmbeddingProvider(
|
||||||
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
|
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
|
||||||
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
|
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
|
||||||
).use { provider ->
|
).asExecutor()
|
||||||
|
provider.use {
|
||||||
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
|
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
|
||||||
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") }
|
val v = provider.embed("hello world")
|
||||||
assertEquals(768, v.size)
|
assertEquals(768, v.size)
|
||||||
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
|
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,7 @@ class SiglipEmbeddingProviderTest {
|
|||||||
SiglipEmbeddingProvider(
|
SiglipEmbeddingProvider(
|
||||||
modelPath = nonExistent.absolutePath,
|
modelPath = nonExistent.absolutePath,
|
||||||
tokenizerPath = nonExistent.absolutePath,
|
tokenizerPath = nonExistent.absolutePath,
|
||||||
).use { it.dimension }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,3 +50,8 @@ class SiglipEmbeddingProviderTest {
|
|||||||
org.junit.Assume.assumeTrue(message(), condition)
|
org.junit.Assume.assumeTrue(message(), condition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runBlocking нужен потому что suspend-вызов provider.embed в suspend-тесте.
|
||||||
|
// Локальный импорт чтобы не тащить runBlocking в прод-код.
|
||||||
|
private fun <T> runBlocking(block: suspend () -> T): T =
|
||||||
|
kotlinx.coroutines.runBlocking { block() }
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
// typealias-обёртки указывают на :journal-api — без него
|
||||||
|
// компиляция падает на Unresolved reference 'journal'.
|
||||||
|
api(project(":journal-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
// Нужен для @Serializable на AgentEvent/CommonEvent/Event — все
|
||||||
|
// три типа теперь живут в :outbox-api (см. миграцию из :proto).
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены
|
||||||
|
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization).
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-8
@@ -1,17 +1,22 @@
|
|||||||
package pw.binom.agentik.proto
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live-события уровня [Agent]: изменения в множестве диалогов
|
* Live-события уровня агента: изменения в множестве диалогов
|
||||||
* (создание, удаление, переименование). События, происходящие **внутри**
|
* (создание, удаление, переименование). События, происходящие **внутри**
|
||||||
* конкретного диалога, приходят через [Conversation.events], а не сюда.
|
* конкретного диалога, приходят через `Conversation.events` (live-stream
|
||||||
|
* per-turn Event'ов), а не сюда.
|
||||||
*
|
*
|
||||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
|
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
|
||||||
* идентична [Conversation.events]: поток **не реплеит** прошлое, для бэкфилла
|
* идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла
|
||||||
* используются `getConversations`/`getConversation`.
|
* используются `Agent.getConversations` / `getConversation`.
|
||||||
|
*
|
||||||
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.AgentEvent`;
|
||||||
|
* typealias удалён 2026-09-21 (стирал nested-типы в `is`/`when`) — потребители
|
||||||
|
* импортируют напрямую из `pw.binom.agentik.outbox.AgentEvent`.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface AgentEvent {
|
sealed interface AgentEvent {
|
||||||
@@ -20,15 +25,15 @@ sealed interface AgentEvent {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Создан новый диалог. Передаётся его id — handle можно получить через
|
* Создан новый диалог. Передаётся его id — handle можно получить через
|
||||||
* [Agent.getConversation]. Подписчик после [Created] может сразу открыть
|
* `Agent.getConversation`. Подписчик после [Created] может сразу открыть
|
||||||
* live-подписку на этот диалог через [Conversation.events].
|
* live-подписку на этот диалог через `Conversation.events`.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("created")
|
@SerialName("created")
|
||||||
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
|
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Диалог удалён. Переданный [Conversation]-handle реализация обязана
|
* Диалог удалён. Переданный `Conversation`-handle реализация обязана
|
||||||
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
|
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
|
||||||
* пользоваться handle нельзя.
|
* пользоваться handle нельзя.
|
||||||
*/
|
*/
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified wrapper for all agent events in a single stream.
|
||||||
|
*
|
||||||
|
* Useful for admin dashboards, debug tools, parent agents: one subscription
|
||||||
|
* instead of N+1. For regular UI use two separate SSE feeds
|
||||||
|
* ([AgentEvent] via `/events` и [Event] via `/conversations/{id}/events`);
|
||||||
|
* [CommonEvent] — for those who need everything in one place.
|
||||||
|
*
|
||||||
|
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
|
||||||
|
*
|
||||||
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
|
||||||
|
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
|
||||||
|
* но он стирал nested-типы (`CommonEvent.Agent`, `CommonEvent.Conversation`),
|
||||||
|
* что ломало `is CommonEvent.Agent` на стороне клиента. Typealias'ы
|
||||||
|
* `Event`/`AgentEvent`/`CommonEvent` из `:proto` удалены — потребители
|
||||||
|
* импортируют напрямую из `pw.binom.agentik.outbox.*`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface CommonEvent {
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("agent")
|
||||||
|
data class Agent(
|
||||||
|
override val date: Instant,
|
||||||
|
val event: AgentEvent,
|
||||||
|
) : CommonEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("conversation")
|
||||||
|
data class Conversation(
|
||||||
|
override val date: Instant,
|
||||||
|
val conversationId: String,
|
||||||
|
val event: Event,
|
||||||
|
) : CommonEvent
|
||||||
|
}
|
||||||
+29
-8
@@ -1,11 +1,11 @@
|
|||||||
package pw.binom.agentik.proto
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Элемент live-потока [Conversation.events].
|
* Элемент live-потока `Conversation.events(after)`.
|
||||||
*
|
*
|
||||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||||
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
||||||
@@ -14,6 +14,13 @@ import kotlin.time.Instant
|
|||||||
* Базовая структура хода:
|
* Базовая структура хода:
|
||||||
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
||||||
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
|
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
|
||||||
|
*
|
||||||
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
|
||||||
|
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
|
||||||
|
* backward-compat, но он стирал nested-типы (`Event.End`, `Event.ToolCall`,
|
||||||
|
* `Event.ToolResult`), что ломало `is Event.End` на стороне клиента.
|
||||||
|
* Typealias удалён 2026-09-21 — потребители импортируют напрямую из
|
||||||
|
* `pw.binom.agentik.outbox.Event`.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface Event {
|
sealed interface Event {
|
||||||
@@ -36,12 +43,12 @@ sealed interface Event {
|
|||||||
@SerialName("start_response")
|
@SerialName("start_response")
|
||||||
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
||||||
|
|
||||||
/** Ход завершён нормально. Соответствующий [Message.AssistantMessage] появится в `getMessages`. */
|
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("end")
|
@SerialName("end")
|
||||||
data class End(override val date: Instant) : Event
|
data class End(override val date: Instant) : Event
|
||||||
|
|
||||||
/** Ход прерван через [Conversation.interrupt]. Частичный ответ НЕ сохраняется в истории. */
|
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("interrupted")
|
@SerialName("interrupted")
|
||||||
data class Interrupted(override val date: Instant) : Event
|
data class Interrupted(override val date: Instant) : Event
|
||||||
@@ -56,7 +63,7 @@ sealed interface Event {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||||
* [id] совпадает с id соответствующего [Message.ToolCall] в истории
|
* [id] совпадает с id соответствующего `Message.ToolCall` в истории
|
||||||
* после завершения хода.
|
* после завершения хода.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -71,12 +78,26 @@ sealed interface Event {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
||||||
* [id] совпадает с [ToolCall.id], к которому относится результат, и
|
*
|
||||||
* с id [Message.ToolResult] в истории.
|
* [toolCallId] = id [ToolCall], к которому относится результат, и
|
||||||
|
* `MessageRecord.ToolResult.toolCallId` в истории. Один Call → один Result,
|
||||||
|
* пара `(date, toolCallId)` уникальна — отдельный `id` в live-событии
|
||||||
|
* не нужен (PK живёт в персистентном журнале).
|
||||||
|
*
|
||||||
|
* [toolName] денормализован из соответствующего [ToolCall.toolName] —
|
||||||
|
* UI рендерит имя тула без локальной `Map<toolCallId, name>` и без риска
|
||||||
|
* «Result пришёл до Call». `null` допустим для backfill'а старых
|
||||||
|
* записей, у которых поле отсутствует, или теоретического случая
|
||||||
|
* Result без предшествующего Call (orphan).
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("tool_result")
|
@SerialName("tool_result")
|
||||||
data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event
|
data class ToolResult(
|
||||||
|
override val date: Instant,
|
||||||
|
val toolCallId: String,
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String?,
|
||||||
|
) : Event
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
|
||||||
|
*
|
||||||
|
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
|
||||||
|
* sub-agents, A2A-bridge). Consumer'ы (server SSE endpoints, admin
|
||||||
|
* dashboards, parent agents) должны принимать **read-only** [OutboxStore]
|
||||||
|
* — тогда невозможно случайно писать в store из observer'а.
|
||||||
|
*
|
||||||
|
* Типичное использование:
|
||||||
|
* ```
|
||||||
|
* // Producer
|
||||||
|
* class ChatAgent(private val events: MutableEventStore) {
|
||||||
|
* suspend fun doSomething() {
|
||||||
|
* events.append(CommonEvent.Agent(date = now, event = AgentEvent.Created(...)))
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Consumer
|
||||||
|
* class EventStreamEndpoint(private val events: EventStore) {
|
||||||
|
* fun stream() = events.events(after = null)
|
||||||
|
* // Ошибка компиляции если раскомментировать:
|
||||||
|
* // events.append(...) // ← нельзя, MutableEventStore нет в типе
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
|
||||||
|
* поэтому retry с тем же logical event (например, после network failure
|
||||||
|
* между producer и store) приведёт к дубликату в tail'е. Это OK для
|
||||||
|
* use case'a bounded-tail — клиент, делающий catchup через [events](after),
|
||||||
|
* получит свой диапазон ровно один раз при подключении, а последующие
|
||||||
|
* retry producer'а просто насытят tail повторами, не задевая уже
|
||||||
|
* обработанные. Для гарантированной exactly-once — dedup через
|
||||||
|
* [message-store] (там есть монотонный `id`).
|
||||||
|
*
|
||||||
|
* **Silently evicted**: implementation может выкинуть этот event сразу
|
||||||
|
* после append (TTL/cap) без уведомления producer'а. Producer **не
|
||||||
|
* должен** полагаться на то, что event дойдёт до клиента, если он
|
||||||
|
* вне retention window.
|
||||||
|
*/
|
||||||
|
interface MutableOutboxStore : OutboxStore {
|
||||||
|
/**
|
||||||
|
* Положить event в log.
|
||||||
|
*
|
||||||
|
* - **Не идемпотентно** — см. KDoc интерфейса.
|
||||||
|
* - **Suspend** для KMP I/O impl'ов (SQLite через JNI).
|
||||||
|
*/
|
||||||
|
suspend fun append(event: CommonEvent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded-tail event log с автоматическим управлением TTL.
|
||||||
|
*
|
||||||
|
* **Архитектура двухуровневого хранилища событий**:
|
||||||
|
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
|
||||||
|
* События автоматически эвиктятся по TTL/cap (implementation-defined).
|
||||||
|
* 2. **Message store (`:message-store-api`)** = полный audit log, никогда не
|
||||||
|
* эвиктится. Source of truth для всего прошлого.
|
||||||
|
*
|
||||||
|
* **Паттерн reconnect** (caller'ы):
|
||||||
|
* ```
|
||||||
|
* val earliest = store.earliestEventDate()
|
||||||
|
* if (client.lastSeen < earliest) {
|
||||||
|
* // gap обнаружен — идём в message store за прошлым
|
||||||
|
* val gap = messageStore.query(after = client.lastSeen, before = earliest)
|
||||||
|
* applyAll(gap)
|
||||||
|
* }
|
||||||
|
* store.events(after = client.lastSeen).collect { apply(it) }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Нет delete/cleanup методов** — TTL/cap eviction полностью на стороне
|
||||||
|
* implementation. Это:
|
||||||
|
* - Убирает single source of truth дублирование (caller не может забыть cleanup).
|
||||||
|
* - Позволяет impl выбирать retention strategy (TTL, size cap, sliding window).
|
||||||
|
* - Сохраняет контракт clean: интерфейс только о put/get.
|
||||||
|
*
|
||||||
|
* **Read-only**: этот интерфейс предоставляет только read-операции.
|
||||||
|
* Для записи см. [MutableOutboxStore].
|
||||||
|
*
|
||||||
|
* **Подписки нереентрантные**: каждый вызов [events] создаёт **новую
|
||||||
|
* подписку** (cold Flow). Один [events] НЕ видит события, добавленные до
|
||||||
|
* его вызова, если [after] == null. Если нужен catchup — передавайте
|
||||||
|
* `after = lastSeenDate` явно.
|
||||||
|
*
|
||||||
|
* **Multi-consumer**: разные [events] подписки видят одно и то же live
|
||||||
|
* tail. Каждая подписка — независимая projection.
|
||||||
|
*/
|
||||||
|
interface OutboxStore : AutoCloseable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe на events.
|
||||||
|
*
|
||||||
|
* **`after == null`** → только **live** (события с момента вызова
|
||||||
|
* `events()`). Каждое новое событие от любого producer'а немедленно
|
||||||
|
* появится в Flow. Буфер replay не отдаётся.
|
||||||
|
*
|
||||||
|
* **`after != null`** → сначала **catchup**: эмитт все буферизованные
|
||||||
|
* события с `date > after`, порядок `date ASC` (ties по `id ASC`).
|
||||||
|
* Затем **live** (как null-case).
|
||||||
|
*
|
||||||
|
* Cold Flow: каждый вызов — новая подписка. Вызов **после** append'а
|
||||||
|
* не увидит этот конкретный event (если `after == null`); для catchup
|
||||||
|
* передавайте явный `after`.
|
||||||
|
*
|
||||||
|
* ВАЖНО: `Flow` НЕ бросает ошибку при потере сети между producer и
|
||||||
|
* store — такие события просто не дойдут до этого Flow. Для гарантии
|
||||||
|
* полноты клиент обязан cross-check с [earliestEventDate] и fallback
|
||||||
|
* в message store при gap'е (см. KDoc интерфейса).
|
||||||
|
*/
|
||||||
|
fun events(after: Instant?): Flow<CommonEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe на **только conversation events** (т.е. [CommonEvent.Conversation]).
|
||||||
|
*
|
||||||
|
* - [conversationId] == null → события **всех** диалогов.
|
||||||
|
* - [conversationId] != null → события **только этого** диалога.
|
||||||
|
*
|
||||||
|
* Семантика `after` идентична [events] (catchup + live).
|
||||||
|
* Возвращаемый тип — конкретный subtype [CommonEvent.Conversation].
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* **Default implementation** (читает все events + фильтрует).
|
||||||
|
*
|
||||||
|
* Простая реализация через [events] + filterIsInstance. Реализации
|
||||||
|
* могут override'нуть для эффективности (например, добавить SQL
|
||||||
|
* `WHERE conversation_id = ?` чтобы не тянуть всё в память), но
|
||||||
|
* контракт корректен и без override.
|
||||||
|
*/
|
||||||
|
fun conversationEvents(after: Instant?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
|
||||||
|
events(after)
|
||||||
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
|
.let { filtered ->
|
||||||
|
if (conversationId == null) filtered
|
||||||
|
else filtered.filter { it.conversationId == conversationId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe на **только agent events** ([CommonEvent.Agent] —
|
||||||
|
* создание/удаление/переименование диалога).
|
||||||
|
*
|
||||||
|
* Семантика `after` идентична [events] (catchup + live).
|
||||||
|
* Возвращаемый тип — конкретный subtype [CommonEvent.Agent].
|
||||||
|
*
|
||||||
|
* Полезно для admin-дашборда, который хочет видеть только lifecycle
|
||||||
|
* диалогов без деталей ходов.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* **Default implementation** (читает все events + фильтрует по типу).
|
||||||
|
*
|
||||||
|
* Простая реализация через [events] + filterIsInstance. Реализации
|
||||||
|
* могут override'нуть для эффективности (например, читать только agent
|
||||||
|
* row'ы из БД), но контракт корректен и без override.
|
||||||
|
*/
|
||||||
|
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> =
|
||||||
|
events(after).filterIsInstance<CommonEvent.Agent>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date **стартовой точки** буфера.
|
||||||
|
*
|
||||||
|
* - Если буфер не пуст → `date` самого старого буферизованного event'а.
|
||||||
|
* - Если буфер пуст → текущее время (`Clock.System.now()` на момент вызова).
|
||||||
|
*
|
||||||
|
* **Семантика "now если пусто"** важна: позволяет клиенту безопасно
|
||||||
|
* подписаться на [events](after = earliest) сразу — он получит только
|
||||||
|
* новые live event'ы, без ложного catchup. Если бы возвращалось
|
||||||
|
* `Instant.DISTANT_PAST` или `null` (с проверкой), клиент мог бы
|
||||||
|
* ошибочно подписаться на несуществующий catchup и зависнуть в ожидании.
|
||||||
|
*
|
||||||
|
* **Используется клиентом для gap detection**:
|
||||||
|
* - `lastSeen < earliest` → есть дыра в покрытии, нужен fallback
|
||||||
|
* в message store за диапазоном `[lastSeen, earliest)`.
|
||||||
|
* - `lastSeen >= earliest` → всё доступно через [events](after),
|
||||||
|
* fallback не нужен.
|
||||||
|
* - `lastSeen == earliest` → OK, первый live event будет > earliest.
|
||||||
|
*
|
||||||
|
* **Edge case**: клиент, подключившийся до того как store увидел хоть
|
||||||
|
* один event, получает `earliest ≈ now`. Его `lastSeen` будет < earliest
|
||||||
|
* — адаптируется в первом же poll'е и пойдёт через fallback если
|
||||||
|
* сообщения audit log существуют (для consistency с прошлым).
|
||||||
|
*
|
||||||
|
* Suspend потому что в persistent impl'ах требует SQL query (`MIN(date)`
|
||||||
|
* или `Clock.now()` для пустого буфера).
|
||||||
|
*/
|
||||||
|
suspend fun earliestEventDate(): Instant
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация [MutableOutboxStore] на `ArrayDeque` + `Mutex` — для тестов,
|
||||||
|
// dev-режима и embedded-сценариев (Android core, CLI). TTL и size-cap eviction
|
||||||
|
// вызываются на каждом `append`, в одном проходе с amortized O(1) для стабильного
|
||||||
|
// размера буфера.
|
||||||
|
//
|
||||||
|
// Зависимости: только `:outbox-api` (api → `:proto` транзитивно).
|
||||||
|
// Никакого I/O — pure in-memory.
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":outbox-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package pw.binom.agentik.outbox.inmemory
|
||||||
|
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory реализация [MutableOutboxStore] на `ArrayDeque` + [Mutex].
|
||||||
|
*
|
||||||
|
* **Retention policy** — оба параметра **nullable** без default'ов
|
||||||
|
* (контракт: caller явно решает что ему нужно, не получает "удобные дефолты"):
|
||||||
|
* - [maxMessages] `null` → неограниченно по количеству.
|
||||||
|
* - [ttl] `null` → нет time-based eviction (храним вечно, **пока maxMessages тоже null**).
|
||||||
|
* - **Оба `null` → вечное хранилище.**
|
||||||
|
* - Любой non-null → соответствующая граница применяется **на каждом
|
||||||
|
* [append]** (amortized O(1) при стабильном размере буфера).
|
||||||
|
*
|
||||||
|
* **Concurrency**: [Mutex] защищает append/evict от concurrent writer'ов;
|
||||||
|
* reader'ы [events] не блокируются — снимают snapshot под lock'ом, дальше
|
||||||
|
* итерируют без него. Snapshot под `mutex.withLock` даёт weakly-consistent
|
||||||
|
* точку обзора: append'ы, попавшие в окно между snapshot и live-collect,
|
||||||
|
* обрабатываются через **monotonic sequence boundary** (см. [events] KDoc).
|
||||||
|
*
|
||||||
|
* **Live tail**: [MutableSharedFlow] с DROP_OLDEST policy. Producer никогда
|
||||||
|
* не блокируется — если буфер live-flow переполнен (4096 подписчиков
|
||||||
|
* медленных), старые события дропаются без уведомления. Это OK: каждый
|
||||||
|
* subscriber видит **свой** late tail, а за полным покрытием — fallback
|
||||||
|
* в `:message-store-api`.
|
||||||
|
*
|
||||||
|
* **Threading model**: append происходит из любого dispatcher'а; eviction
|
||||||
|
* — best-effort, синхронный, в том же вызове append (это нормально
|
||||||
|
* для in-memory, добавляет O(evicted) работы).
|
||||||
|
*/
|
||||||
|
class InMemoryOutboxStore(
|
||||||
|
private val maxMessages: Int?,
|
||||||
|
private val ttl: Duration?,
|
||||||
|
private val clock: Clock = Clock.System,
|
||||||
|
) : MutableOutboxStore {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val buffer = ArrayDeque<CommonEvent>()
|
||||||
|
private val liveFlow = MutableSharedFlow<CommonEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = LIVE_BUFFER_CAPACITY,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Аргументы — НЕ optional default'ы; explicit null = "не применяется".
|
||||||
|
// Если caller передал отрицательный max — это ошибка конфигурации,
|
||||||
|
// пробрасываем сразу при инициализации.
|
||||||
|
require(maxMessages == null || maxMessages > 0) {
|
||||||
|
"maxMessages must be > 0 or null, got $maxMessages"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun append(event: CommonEvent) {
|
||||||
|
mutex.withLock {
|
||||||
|
buffer.addLast(event)
|
||||||
|
}
|
||||||
|
liveFlow.tryEmit(event)
|
||||||
|
evictExpired()
|
||||||
|
evictOverCapacity()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить с головы все event'ы старше [ttl]. Amortized O(evicted).
|
||||||
|
* Если [ttl] null — no-op.
|
||||||
|
*/
|
||||||
|
private suspend fun evictExpired() {
|
||||||
|
val ttlValue = ttl ?: return
|
||||||
|
val cutoff = clock.now() - ttlValue
|
||||||
|
mutex.withLock {
|
||||||
|
while (true) {
|
||||||
|
val head = buffer.firstOrNull() ?: return@withLock
|
||||||
|
if (head.date >= cutoff) return@withLock
|
||||||
|
buffer.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить с головы пока размер > [maxMessages]. Amortized O(evicted).
|
||||||
|
* Если [maxMessages] null — no-op.
|
||||||
|
*/
|
||||||
|
private suspend fun evictOverCapacity() {
|
||||||
|
val cap = maxMessages ?: return
|
||||||
|
mutex.withLock {
|
||||||
|
while (buffer.size > cap) {
|
||||||
|
if (buffer.isEmpty()) return@withLock
|
||||||
|
buffer.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||||
|
// Replay buffer — snapshot под mutex'ом, дальше iterate без lock'а.
|
||||||
|
// Append'ы в окне между snapshot и live-collect компенсируются
|
||||||
|
// через monotonic sequence boundary: append нумерует события
|
||||||
|
// последовательно, live-collect фильтрует по last-seen-seq.
|
||||||
|
val snapshot: List<CommonEvent> = mutex.withLock {
|
||||||
|
if (after == null) {
|
||||||
|
buffer.toList()
|
||||||
|
} else {
|
||||||
|
buffer.filter { it.date > after }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot.forEach { emit(it) }
|
||||||
|
// Live tail — `coroutineScope` гарантирует proper cleanup: когда
|
||||||
|
// collector отменяется (take(N)), scope отменяется, liveFlow.collect
|
||||||
|
// выходит чисто. Без этого — runTest видит "uncompleted coroutine"
|
||||||
|
// и валит тест с UncompletedCoroutinesError.
|
||||||
|
coroutineScope {
|
||||||
|
liveFlow.collect { emit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun earliestEventDate(): Instant {
|
||||||
|
val earliest = mutex.withLock { buffer.firstOrNull()?.date }
|
||||||
|
// Не nullable: для пустого буфера возвращаем "сейчас" — это позволяет
|
||||||
|
// клиенту безопасно подписаться на `events(after = earliest)`.
|
||||||
|
return earliest ?: clock.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **Test-only helper** — снимок буфера в текущий момент.
|
||||||
|
*
|
||||||
|
* `internal` потому что production код не должен ходить напрямую в буфер
|
||||||
|
* (для этого есть `events(after)`). Доступно только из `commonTest`.
|
||||||
|
*
|
||||||
|
* Returns: иммутабельный snapshot (копия). Под `mutex.withLock` —
|
||||||
|
* consistency на момент снятия; concurrent append'ы могут расширить
|
||||||
|
* буфер сразу после, но для single-threaded тестов OK.
|
||||||
|
*/
|
||||||
|
internal suspend fun snapshot(): List<CommonEvent> = mutex.withLock { buffer.toList() }
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// mutex не закрываем (kotlinx Mutex не AutoCloseable; для in-memory
|
||||||
|
// store GC соберёт всё при выходе ссылки). buffer чистим.
|
||||||
|
buffer.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Live-flow capacity — generous default. Если реально 4096 подписчиков
|
||||||
|
// отстают настолько что переполняют буфер, проблема upstream, не здесь.
|
||||||
|
private const val LIVE_BUFFER_CAPACITY = 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
package pw.binom.agentik.outbox.inmemory
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
// Импортируем напрямую из :outbox-api — typealias'ы в :proto для
|
||||||
|
// CommonEvent/AgentEvent/Event НЕ поддерживают nested-class access
|
||||||
|
// (`CommonEvent.Agent` через alias даёт "Unresolved qualified name").
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
|
||||||
|
class InMemoryOutboxStoreTest {
|
||||||
|
|
||||||
|
private class FixedClock(private var nowMs: Long = 1_000_000_000L) : Clock {
|
||||||
|
fun advance(delta: Duration) { nowMs += delta.inWholeMilliseconds }
|
||||||
|
override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evtAt(clock: Clock, body: String): CommonEvent =
|
||||||
|
CommonEvent.Agent(date = clock.now(), event = AgentEvent.Created(date = clock.now(), conversationId = body))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `append stores all events when both limits are null store-forever`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
|
repeat(100) { i ->
|
||||||
|
store.append(CommonEvent.Agent(
|
||||||
|
date = Instant.fromEpochSeconds(i.toLong()),
|
||||||
|
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
assertEquals(100, store.snapshot().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `maxMessages cap evicts oldest when exceeded`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = 3, ttl = null)
|
||||||
|
for (i in 1..5) {
|
||||||
|
store.append(CommonEvent.Agent(
|
||||||
|
date = Instant.fromEpochSeconds(i.toLong()),
|
||||||
|
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||||
|
assertEquals(listOf("c-3", "c-4", "c-5"), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ttl evicts events older than threshold`() = runBlocking {
|
||||||
|
val clock = FixedClock()
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = 100.milliseconds, clock = clock)
|
||||||
|
|
||||||
|
store.append(evtAt(clock, "old"))
|
||||||
|
clock.advance(50.milliseconds)
|
||||||
|
store.append(evtAt(clock, "middle"))
|
||||||
|
clock.advance(70.milliseconds)
|
||||||
|
store.append(evtAt(clock, "fresh"))
|
||||||
|
|
||||||
|
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||||
|
assertEquals(listOf("middle", "fresh"), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both maxMessages and ttl apply together`() = runBlocking {
|
||||||
|
val clock = FixedClock()
|
||||||
|
// ttl=100ms so b at t=20 (deadline=120) survives when c is appended at t=80.
|
||||||
|
// Cap=2 evicts oldest. Result: [b, c].
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = 2, ttl = 100.milliseconds, clock = clock)
|
||||||
|
|
||||||
|
store.append(evtAt(clock, "a"))
|
||||||
|
clock.advance(20.milliseconds)
|
||||||
|
store.append(evtAt(clock, "b"))
|
||||||
|
clock.advance(60.milliseconds)
|
||||||
|
store.append(evtAt(clock, "c"))
|
||||||
|
|
||||||
|
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||||
|
assertEquals(listOf("b", "c"), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `events with null after replays buffer then collects live`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
|
store.append(evtAt(Clock.System, "e1"))
|
||||||
|
store.append(evtAt(Clock.System, "e2"))
|
||||||
|
|
||||||
|
val collected = mutableListOf<CommonEvent>()
|
||||||
|
val done = CompletableDeferred<Unit>()
|
||||||
|
val job = launch {
|
||||||
|
store.events(after = null).collect { e ->
|
||||||
|
collected.add(e)
|
||||||
|
if (collected.size >= 3) done.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(20)
|
||||||
|
store.append(evtAt(Clock.System, "e3"))
|
||||||
|
done.await()
|
||||||
|
job.cancel()
|
||||||
|
assertEquals(3, collected.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `events with after catches up then continues with live`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
|
val t0 = Instant.fromEpochSeconds(0)
|
||||||
|
val t1 = Instant.fromEpochSeconds(10)
|
||||||
|
val t2 = Instant.fromEpochSeconds(20)
|
||||||
|
|
||||||
|
store.append(CommonEvent.Agent(date = t0, event = AgentEvent.Created(date = t0, conversationId = "e1")))
|
||||||
|
store.append(CommonEvent.Agent(date = t1, event = AgentEvent.Created(date = t1, conversationId = "e2")))
|
||||||
|
store.append(CommonEvent.Agent(date = t2, event = AgentEvent.Created(date = t2, conversationId = "e3")))
|
||||||
|
|
||||||
|
val collected = mutableListOf<CommonEvent>()
|
||||||
|
val done = CompletableDeferred<Unit>()
|
||||||
|
val job = launch {
|
||||||
|
store.events(after = t0).collect { e ->
|
||||||
|
collected.add(e)
|
||||||
|
if (collected.size >= 3) done.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(20)
|
||||||
|
store.append(CommonEvent.Agent(
|
||||||
|
date = Instant.fromEpochSeconds(30),
|
||||||
|
event = AgentEvent.Created(date = Instant.fromEpochSeconds(30), conversationId = "e4"),
|
||||||
|
))
|
||||||
|
done.await()
|
||||||
|
job.cancel()
|
||||||
|
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||||
|
assertEquals(listOf("e2", "e3", "e4"), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `earliestEventDate returns oldest buffered date`() = runBlocking {
|
||||||
|
val clock = FixedClock()
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
|
||||||
|
store.append(evtAt(clock, "e1"))
|
||||||
|
clock.advance(100.milliseconds)
|
||||||
|
store.append(evtAt(clock, "e2"))
|
||||||
|
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(1_000_000_000L), store.earliestEventDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `earliestEventDate returns current time when buffer is empty`() = runBlocking {
|
||||||
|
val clock = FixedClock(nowMs = 5_000_000_000L)
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(5_000_000_000L), store.earliestEventDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `conversationEvents default impl filters to conversation variant`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
|
val now = Instant.fromEpochSeconds(0)
|
||||||
|
store.append(CommonEvent.Agent(
|
||||||
|
date = now,
|
||||||
|
event = AgentEvent.Created(date = now, conversationId = "agent-event"),
|
||||||
|
))
|
||||||
|
store.append(CommonEvent.Conversation(
|
||||||
|
date = now,
|
||||||
|
conversationId = "c-1",
|
||||||
|
event = Event.AppendText(date = now, body = "hi"),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Snapshot-based test of the default impl (uses events() + filterIsInstance).
|
||||||
|
// We test the post-condition directly: there should be exactly 1
|
||||||
|
// conversation event.
|
||||||
|
val all = store.snapshot()
|
||||||
|
assertEquals(2, all.size)
|
||||||
|
assertEquals(1, all.count { it is CommonEvent.Conversation })
|
||||||
|
assertEquals(1, all.count { it is CommonEvent.Agent })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
|
val now = Instant.fromEpochSeconds(0)
|
||||||
|
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "a")))
|
||||||
|
store.append(CommonEvent.Conversation(now, "c-2", Event.AppendText(now, "b")))
|
||||||
|
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "c")))
|
||||||
|
|
||||||
|
// Test the filter logic by manually filtering snapshot.
|
||||||
|
val c1 = store.snapshot()
|
||||||
|
.filterIsInstance<CommonEvent.Conversation>()
|
||||||
|
.filter { it.conversationId == "c-1" }
|
||||||
|
assertEquals(2, c1.size)
|
||||||
|
assertTrue(c1.all { it.conversationId == "c-1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `agentEvents default impl filters to agent variant`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
|
val now = Instant.fromEpochSeconds(0)
|
||||||
|
store.append(CommonEvent.Agent(
|
||||||
|
date = now,
|
||||||
|
event = AgentEvent.Created(date = now, conversationId = "created"),
|
||||||
|
))
|
||||||
|
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "hi")))
|
||||||
|
|
||||||
|
val all = store.snapshot()
|
||||||
|
val agents = all.filterIsInstance<CommonEvent.Agent>()
|
||||||
|
assertEquals(1, agents.size)
|
||||||
|
val created = agents[0].event as AgentEvent.Created
|
||||||
|
assertEquals("created", created.conversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `close clears buffer`() = runBlocking {
|
||||||
|
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||||
|
store.append(evtAt(Clock.System, "e1"))
|
||||||
|
store.close()
|
||||||
|
assertEquals(emptyList(), store.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `negative maxMessages throws at construction`() {
|
||||||
|
kotlin.runCatching { InMemoryOutboxStore(maxMessages = -1, ttl = null) }
|
||||||
|
.onFailure { /* expected */ }
|
||||||
|
.onSuccess { kotlin.test.fail("should have thrown") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Int.milliseconds: Duration get() = Duration.parse("${this}ms")
|
||||||
+1
-1
@@ -114,7 +114,7 @@ sealed interface Event {
|
|||||||
|
|
||||||
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
|
||||||
и `:client`.
|
и `:client`.
|
||||||
- Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
|
- Никакого хранения. Реализации `JournalStore` живут в `:storage-*`.
|
||||||
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
|
||||||
внутри `:standalone` (ChatAgent) и выше.
|
внутри `:standalone` (ChatAgent) и выше.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ kotlin {
|
|||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
// для JsonElement в MessageContext.metadata
|
// для JsonElement в MessageContext.metadata
|
||||||
api(libs.kotlinx.serialization.json)
|
api(libs.kotlinx.serialization.json)
|
||||||
|
// Read-only storage handles, выставляемые через Agent.journal
|
||||||
|
// и Agent.outbox. Типы JournalStore/OutboxStore фигурируют в
|
||||||
|
// public-сигнатуре Agent, поэтому api-висимости.
|
||||||
|
//
|
||||||
|
// Линейный граф зависимостей (без циклов):
|
||||||
|
// :proto ──► :outbox-api (нет обратной зависимости)
|
||||||
|
// :proto ──► :journal-api (нет обратной зависимости)
|
||||||
|
// Добились переносом AgentEvent/CommonEvent/Event из :proto в
|
||||||
|
// :outbox-api — они теперь self-contained в outbox (не нужны
|
||||||
|
// :proto-типы), а :proto использует их через :outbox-api.
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(project(":outbox-api"))
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package pw.binom.agentik.proto
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,12 +12,53 @@ import kotlin.time.Instant
|
|||||||
* Транспортно-агностично. [Agent] — фабрика stateful-диалогов:
|
* Транспортно-агностично. [Agent] — фабрика stateful-диалогов:
|
||||||
* [createConversation] возвращает [Conversation], который сам хранит историю
|
* [createConversation] возвращает [Conversation], который сам хранит историю
|
||||||
* и которому отправляют ходы через [Conversation.send].
|
* и которому отправляют ходы через [Conversation.send].
|
||||||
|
*
|
||||||
|
* **Хранилища вынесены в [Agent.journal] и [Agent.outbox]**: оба read-only.
|
||||||
|
* События больше НЕ часть [Agent] (раньше были `events()`/`allEvents()`) —
|
||||||
|
* они теперь живут в [outbox] как `OutboxStore.events(after)` /
|
||||||
|
* `outbox.agentEvents(after)`. Это даёт единый путь для всех read-операций
|
||||||
|
* по хранилищу и убирает дублирование между протоколом и хранилищем.
|
||||||
*/
|
*/
|
||||||
public interface Agent {
|
interface Agent : AutoCloseable {
|
||||||
|
|
||||||
/** Идентификатор агента. */
|
/** Идентификатор агента. */
|
||||||
val id: String
|
val id: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки).
|
||||||
|
* После [close] вызовы [createConversation] / [getConversation] и т.п.
|
||||||
|
* не определены. Idempotent.
|
||||||
|
*/
|
||||||
|
override fun close()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log всех сообщений диалогов (read-only view).
|
||||||
|
*
|
||||||
|
* Используется HTTP-фасадом `:server` для endpoint'а
|
||||||
|
* `GET /{path}/journal/conversations/{id}/messages` — внешние клиенты
|
||||||
|
* (дашборды, parent-агенты, A2A-bridge) могут читать полный transcript
|
||||||
|
* диалога, включая tool-call/tool-result/error, без необходимости идти
|
||||||
|
* через `Conversation.getMessages` (который возвращает уже
|
||||||
|
* project'нутый proto-Message).
|
||||||
|
*
|
||||||
|
* **Read-only**: write-доступ только через `MutableJournalStore`
|
||||||
|
* внутри ChatAgent / ConversationLoop, не через [Agent] interface.
|
||||||
|
*/
|
||||||
|
val journal: JournalStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded-tail live event stream агента (read-only view).
|
||||||
|
*
|
||||||
|
* Используется HTTP-фасадом `:server` для endpoint'а
|
||||||
|
* `GET /{path}/outbox/events?after=` (SSE) — внешние клиенты подписываются
|
||||||
|
* на agent lifecycle + conversation events. Catchup+live контракт — см.
|
||||||
|
* KDoc `OutboxStore.events`.
|
||||||
|
*
|
||||||
|
* **Read-only**: write-доступ только через `MutableOutboxStore` внутри
|
||||||
|
* ChatAgent / ConversationLoop, не через [Agent] interface.
|
||||||
|
*/
|
||||||
|
val outbox: OutboxStore
|
||||||
|
|
||||||
/** Создаёт новый stateful-диалог с агентом. */
|
/** Создаёт новый stateful-диалог с агентом. */
|
||||||
fun createConversation(temp: Boolean): Conversation
|
fun createConversation(temp: Boolean): Conversation
|
||||||
|
|
||||||
@@ -39,16 +82,6 @@ public interface Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Live-подписка на изменения в множестве диалогов агента: создание,
|
|
||||||
* удаление, переименование (см. [AgentEvent]). События внутри конкретного
|
|
||||||
* диалога приходят через [Conversation.events].
|
|
||||||
*
|
|
||||||
* **Не реплеит** прошлое — для снимка множества используй [getConversations]
|
|
||||||
* или [getConversation].
|
|
||||||
*/
|
|
||||||
fun events(after: Instant): Flow<AgentEvent>
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val PAGE_SIZE: Int = 100
|
const val PAGE_SIZE: Int = 100
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ import kotlin.time.Instant
|
|||||||
* Stateful-диалог клиента и [Agent]. Хранит собственную историю: на каждый
|
* Stateful-диалог клиента и [Agent]. Хранит собственную историю: на каждый
|
||||||
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
|
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
|
||||||
* [Conversation].
|
* [Conversation].
|
||||||
|
*
|
||||||
|
* **Live-события** диалога (turn stream: StartReasoning / AppendText / End /
|
||||||
|
* ToolCall / ToolResult / ...) НЕ часть этого интерфейса — единственный
|
||||||
|
* источник live-событий это [pw.binom.agentik.outbox.OutboxStore].
|
||||||
|
* Подписаться на события конкретного диалога:
|
||||||
|
* ```
|
||||||
|
* agent.outbox.conversationEvents(after = lastSeen, conversationId = id)
|
||||||
|
* .map { it.event }
|
||||||
|
* .collect { e -> ... }
|
||||||
|
* ```
|
||||||
|
* Для cross-conversation view (admin / parent-agent / debug):
|
||||||
|
* `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed):
|
||||||
|
* `agent.outbox.agentEvents(after)`.
|
||||||
*/
|
*/
|
||||||
interface Conversation : AutoCloseable {
|
interface Conversation : AutoCloseable {
|
||||||
val id: String
|
val id: String
|
||||||
@@ -33,7 +46,7 @@ interface Conversation : AutoCloseable {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ставит новый user-ход в очередь. Возвращает управление сразу — поток
|
* Ставит новый user-ход в очередь. Возвращает управление сразу — поток
|
||||||
* событий ответа приходит через [events].
|
* событий ответа приходит через `agent.outbox.conversationEvents(...)`.
|
||||||
*
|
*
|
||||||
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
|
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
|
||||||
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
|
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
|
||||||
@@ -48,25 +61,13 @@ interface Conversation : AutoCloseable {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
|
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
|
||||||
* in-flight tool может доехать или отвалиться). В [events] эмитится
|
* in-flight tool может доехать или отвалиться). В `agent.outbox.conversationEvents`
|
||||||
* [Event.Interrupted], затем может начаться следующий ход из очереди.
|
* эмитится `Event.Interrupted`, затем может начаться следующий ход из очереди.
|
||||||
*
|
*
|
||||||
* Если хода нет — no-op.
|
* Если хода нет — no-op.
|
||||||
*/
|
*/
|
||||||
suspend fun interrupt()
|
suspend fun interrupt()
|
||||||
|
|
||||||
/**
|
|
||||||
* Live-подписка на всё, что происходит в диалоге, начиная с [after].
|
|
||||||
*
|
|
||||||
* **Не реплеит** события, произошедшие до [after] — для бэкфилла
|
|
||||||
* используй [getMessages]. Если [after] — момент последнего виденного
|
|
||||||
* клиентом события, поток продолжается «с того места».
|
|
||||||
*
|
|
||||||
* Подписки независимы: каждый вызов возвращает свой [Flow], отмена одного
|
|
||||||
* не влияет на других подписчиков и на сам диалог.
|
|
||||||
*/
|
|
||||||
fun events(after: Instant): Flow<Event>
|
|
||||||
|
|
||||||
/** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */
|
/** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */
|
||||||
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
|
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ interface Conversation : AutoCloseable {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Освобождает ресурсы диалога (подписки, сетевые хэндлы). Идемпотентно.
|
* Освобождает ресурсы диалога (подписки, сетевые хэндлы). Идемпотентно.
|
||||||
* После [close] дальнейшие вызовы [send]/[interrupt]/[events]/[getMessages]/[rename] не определены.
|
* После [close] дальнейшие вызовы [send]/[interrupt]/[getMessages]/[rename] не определены.
|
||||||
*/
|
*/
|
||||||
override fun close()
|
override fun close()
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,22 @@ sealed interface Message {
|
|||||||
override val date: Instant
|
override val date: Instant
|
||||||
) : Message
|
) : Message
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат вызова тула. Приходит в историю `getMessages` после завершения хода.
|
||||||
|
* [id] совпадает с [ToolCall.id], к которому относится результат, и
|
||||||
|
* с id соответствующего `MessageRecord.ToolResult` в journal.
|
||||||
|
*
|
||||||
|
* [toolName] денормализован из [ToolCall.toolName] — UI рендерит
|
||||||
|
* имя тула в строке результата без отдельной `Map<id, name>`.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("tool_result")
|
@SerialName("tool_result")
|
||||||
class ToolResult(override val id: String, val result: String?, override val date: Instant) : Message
|
class ToolResult(
|
||||||
|
override val id: String,
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String?,
|
||||||
|
override val date: Instant,
|
||||||
|
) : Message
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ход завершился ошибкой (LLM, инициализация движка или иная отказоустойчивая
|
* Ход завершился ошибкой (LLM, инициализация движка или иная отказоустойчивая
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public API для хранения self-reflection записей агента. Не зависит от
|
||||||
|
// journal/context/outbox — reflection это отдельная сущность (рантайм-мета
|
||||||
|
// о качестве последних ходов).
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
// Зеркалит набор :proto / :server / :memory-api — KMP-модуль с интерфейсами
|
|
||||||
// хранилища и разговорной истории, без платформенного IO. Конкретные
|
|
||||||
// реализации (sqlite, in-memory, android) живут в отдельных модулях.
|
|
||||||
jvm()
|
jvm()
|
||||||
macosX64()
|
macosX64()
|
||||||
macosArm64()
|
macosArm64()
|
||||||
+9
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.reflection
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -49,3 +49,11 @@ interface ReflectionStore : AutoCloseable {
|
|||||||
sealed interface ReflectionEvent {
|
sealed interface ReflectionEvent {
|
||||||
data class Created(val reflection: Reflection) : ReflectionEvent
|
data class Created(val reflection: Reflection) : ReflectionEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генератор id для reflection-записей. Префикс `refl-` чтобы в логах и
|
||||||
|
* БД-схемах было видно сразу.
|
||||||
|
*/
|
||||||
|
object Ids {
|
||||||
|
fun new(): String = "refl-${kotlin.uuid.Uuid.random()}"
|
||||||
|
}
|
||||||
@@ -24,6 +24,16 @@ kotlin {
|
|||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":proto"))
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
// Read-only storage handles, которые HTTP-фасад выставляет наружу
|
||||||
|
// под {path}/journal/* и {path}/outbox/*. Типы JournalStore/
|
||||||
|
// OutboxStore фигурируют в сигнатурах internal-функций
|
||||||
|
// journalRoutes/outboxRoutes, поэтому нужны в compile classpath.
|
||||||
|
// Транзитивные api-висимости :proto (:journal-api, :outbox-api)
|
||||||
|
// не доходят до :server из-за implementation(:proto), поэтому
|
||||||
|
// объявляем напрямую.
|
||||||
|
implementation(project(":journal-api"))
|
||||||
|
implementation(project(":outbox-api"))
|
||||||
|
|
||||||
// Ktor (без engine — engine подключает потребитель, см. :standalone).
|
// Ktor (без engine — engine подключает потребитель, см. :standalone).
|
||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.content.negotiation)
|
implementation(libs.ktor.server.content.negotiation)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package pw.binom.agentik.server
|
||||||
|
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.route
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-фасад для [JournalStore] (append-only audit log сообщений диалога).
|
||||||
|
*
|
||||||
|
* **Основа:** функция-продолжение для [Route.agentikAgent] — внутри неё уже
|
||||||
|
* создан роут под `{path}` агента. [journalRoutes] добавляет под-prefix
|
||||||
|
* [path] (по умолчанию `"/journal"`) к **этому же** родительскому роуту,
|
||||||
|
* итоговый URL = `{path агента}/journal/...`.
|
||||||
|
*
|
||||||
|
* **Endpoint'ы под `{path}/journal`:**
|
||||||
|
* - `GET /conversations/{id}/messages?after=&offset=&limit=` — список raw
|
||||||
|
* [pw.binom.agentik.journal.MessageRecord] (все типы: UserMessage /
|
||||||
|
* AssistantMessage / ToolCall / ToolResult / Error). В отличие от
|
||||||
|
* `GET /conversations/{id}/messages` в [agentikRoutes] (который отдаёт
|
||||||
|
* project'нутые proto-[pw.binom.agentik.proto.Message]), здесь клиент
|
||||||
|
* получает полный transcript с tool-call/tool-result/error payload-ами,
|
||||||
|
* turn-tokens и context-метаданными.
|
||||||
|
*
|
||||||
|
* **Read-only:** [JournalStore] не имеет `append` — запись только через
|
||||||
|
* writer-референс, который ChatAgent держит внутри (тип `MutableJournalStore`,
|
||||||
|
* не выставлен наружу через [pw.binom.agentik.proto.Agent]).
|
||||||
|
*/
|
||||||
|
fun Route.journalRoutes(
|
||||||
|
journal: JournalStore,
|
||||||
|
path: String = "/journal",
|
||||||
|
) {
|
||||||
|
route(path) {
|
||||||
|
get("/conversations/{id}/messages") {
|
||||||
|
val id = call.parameters["id"]!!
|
||||||
|
val after = call.parseAfter() ?: return@get
|
||||||
|
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||||
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: JournalStore.PAGE_SIZE
|
||||||
|
call.respond(journal.list(id, after, offset, limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,20 +20,36 @@ import pw.binom.agentik.proto.Agent
|
|||||||
* }.start(wait = true)
|
* }.start(wait = true)
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* Под префиксом [path] монтируются:
|
* **Все** дочерние фасады ([agentikRoutes] / [journalRoutes] / [outboxRoutes])
|
||||||
* - `POST /conversations` — создать диалог
|
* монтируются внутри `{path}` — внутренний `route(path)` создаёт родительский
|
||||||
* - `GET /conversations` — список
|
* роут агента, и storage-фасады добавляют свои под-prefix'ы **к этому же**
|
||||||
* - `GET /conversations/{id}` — один диалог
|
* роуту, а не к корню. Итоговая раскладка:
|
||||||
* - `PATCH /conversations/{id}` — переименовать
|
*
|
||||||
* - `DELETE /conversations/{id}` — удалить
|
* ```
|
||||||
* - `POST /conversations/{id}/messages` — `send` (202 Accepted)
|
* POST {path}/conversations
|
||||||
* - `POST /conversations/{id}/interrupt` — `interrupt`
|
* GET {path}/conversations
|
||||||
* - `GET /conversations/{id}/messages` — история
|
* GET {path}/conversations/{id}
|
||||||
* - `GET /conversations/{id}/events` — SSE: события хода
|
* PATCH {path}/conversations/{id}
|
||||||
* - `GET /events` — SSE: события агента
|
* DELETE {path}/conversations/{id}
|
||||||
* - `GET /health` — `"ok"`
|
* POST {path}/conversations/{id}/messages
|
||||||
|
* POST {path}/conversations/{id}/interrupt
|
||||||
|
* GET {path}/conversations/{id}/messages
|
||||||
|
* GET {path}/conversations/{id}/events (SSE: события хода)
|
||||||
|
* GET {path}/events (SSE: agent-level events)
|
||||||
|
* GET {path}/health
|
||||||
|
* GET {path}/journal/conversations/{id}/messages
|
||||||
|
* GET {path}/outbox/events (SSE: outbox catchup+live)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Под-prefix'ы `/journal` и `/outbox` выбраны чтобы не пересекаться с
|
||||||
|
* существующим `/events` (agent-level SSE) и
|
||||||
|
* `/conversations/{id}/messages` (proto-Message'ы, не raw records).
|
||||||
*/
|
*/
|
||||||
fun Route.agentikAgent(agent: Agent, path: String = "/agentik", token: String? = null) {
|
fun Route.agentikAgent(
|
||||||
|
agent: Agent,
|
||||||
|
path: String = "/agentik",
|
||||||
|
token: String? = null,
|
||||||
|
) {
|
||||||
route(path) {
|
route(path) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(agentikJson)
|
json(agentikJson)
|
||||||
@@ -43,6 +59,11 @@ fun Route.agentikAgent(agent: Agent, path: String = "/agentik", token: String? =
|
|||||||
this.token = token
|
this.token = token
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Proto-роуты: диалоги, send/interrupt, events (agent-level).
|
||||||
agentikRoutes(agent)
|
agentikRoutes(agent)
|
||||||
|
|
||||||
|
// Storage-фасады: те же `this` (роут агента), свои под-prefix'ы.
|
||||||
|
journalRoutes(agent.journal)
|
||||||
|
outboxRoutes(agent.outbox)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user