Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e583ac8ea | |||
| f878d1c79b | |||
| 266ec38c1b | |||
| e447525059 | |||
| 84f5fd84f3 | |||
| 639c7d1748 | |||
| 5f0e0da361 | |||
| acb4ee6186 | |||
| c0a933d251 | |||
| 29851c047a | |||
| c9995b263e | |||
| 7865eed836 | |||
| 0a7c40688c | |||
| 161be41adf | |||
| 68543357c2 |
@@ -5,6 +5,9 @@
|
||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||
# release.yml для требуемых переменных.
|
||||
#
|
||||
# :agentik-cli / :agentik-tui исключены из сборки (settings.gradle.kts
|
||||
# 2026-09-17/21) — соответствующие шаги shadowJar тут НЕ запускаются.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
@@ -67,15 +70,10 @@ jobs:
|
||||
test -f standalone/build/libs/standalone-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||
|
||||
- name: Build :agentik-cli shadowJar
|
||||
shell: bash
|
||||
run: |
|
||||
./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)"
|
||||
|
||||
# Шага "Build :agentik-cli shadowJar" здесь нет: :agentik-cli исключён из
|
||||
# settings.gradle.kts (2026-09-21). :agentik-tui — тоже исключён (2026-09-17).
|
||||
# Когда/если оба вернутся, добавим отдельные шаги по аналогии с :standalone.
|
||||
#
|
||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||
@@ -18,6 +18,8 @@ out/
|
||||
|
||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||
.cortexkit/
|
||||
# opencode CLI local config (per-machine, не коммитим)
|
||||
config.json
|
||||
.veai/
|
||||
|
||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||
|
||||
@@ -18,8 +18,9 @@ agentik/
|
||||
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
||||
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
||||
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
||||
├── storage-inmemory/ in-memory реализация для тестов и Android
|
||||
├── storage-sqlite/ SQLite реализация для production
|
||||
│ (исторический, см. journal-api / context-api / reflection-api ниже)
|
||||
├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
|
||||
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
|
||||
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||
@@ -89,9 +90,9 @@ curl http://localhost:8080/health
|
||||
- [`:memory-api`](memory-api/README.md) — контракт памяти.
|
||||
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
||||
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
||||
- [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
|
||||
- ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
|
||||
- ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
|
||||
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
||||
|
||||
## Где смотреть версии
|
||||
@@ -124,7 +125,7 @@ SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппирован
|
||||
|
||||
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||
|
||||
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
||||
- `.gitea/ci.yml` — PR-build, прогон тестов, проверка
|
||||
shadowjar'ов.
|
||||
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
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.
|
||||
@@ -0,0 +1,42 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// :proto — read-only Agent interface, который MutableAgent расширяет.
|
||||
// Через api(), иначе downstream-impl ChatAgent не сможет
|
||||
// override suspend-методы Agent.
|
||||
api(project(":proto"))
|
||||
// :memory-api — typealias ConversationTurn на memory-api одноимённый
|
||||
// класс, иначе пер-конво компоненты (skill mining, reflection) не
|
||||
// смогут передать его в SkillMiner.mine() напрямую.
|
||||
api(project(":memory-api"))
|
||||
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
|
||||
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
|
||||
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
|
||||
api(libs.litert.api)
|
||||
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
|
||||
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
|
||||
// listener) — оставлен как api, чтобы downstream не забывал объявить.
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package pw.binom.agentik.agent
|
||||
|
||||
/**
|
||||
* Нашлёпка поверх [MutableAgent].
|
||||
*
|
||||
* Компонент сам регистрирует в агенте свои capability-провайдеры
|
||||
* при [install] и снимает их при [uninstall]. Агент не знает заранее
|
||||
* ни о структуре компонента, ни о его провайдерах — это просто
|
||||
* хук для свободной композиции.
|
||||
*
|
||||
* Ktor-style API:
|
||||
* ```
|
||||
* val agent = ChatAgent(...)
|
||||
* .install(SkillComponent(store, miner))
|
||||
* .install(ReflectionComponent(reflectionStore, reflector))
|
||||
* .install(MemoryComponent(memorySystem))
|
||||
* ```
|
||||
*
|
||||
* Контракт:
|
||||
* - [install] **синхронен**: компонент добавляет свои провайдеры в
|
||||
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
|
||||
* фоновые корутины — компонент запускает их через свой собственный
|
||||
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
|
||||
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
|
||||
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
|
||||
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
|
||||
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
|
||||
*/
|
||||
interface Component {
|
||||
|
||||
/**
|
||||
* Вызывается агентом при [MutableAgent.install].
|
||||
*
|
||||
* Типичные действия: добавить [SystemPromptProvider] в
|
||||
* `agent.systemProviders`, добавить [ToolProvider] в
|
||||
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
|
||||
*/
|
||||
fun install(agent: MutableAgent)
|
||||
|
||||
/**
|
||||
* Вызывается агентом при [MutableAgent.uninstall] или при
|
||||
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
|
||||
* которые добавил в [install], и остановить фоновые джобы.
|
||||
*/
|
||||
fun uninstall(agent: MutableAgent)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package pw.binom.agentik.agent
|
||||
|
||||
/**
|
||||
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
|
||||
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
|
||||
*
|
||||
* [MutableAgent] при создании/закрытии разговора вызывает
|
||||
* [attachConversation] / [detachConversation] на каждом компоненте,
|
||||
* реализующем этот интерфейс. Внутри компонент хранит
|
||||
* [ConversationHandle] (или контекст вокруг него) и подписывается на
|
||||
* нужные события.
|
||||
*
|
||||
* Компонент без [ConversationAware] остаётся чисто agent-level — он
|
||||
* не получает per-conversation хуков.
|
||||
*/
|
||||
interface ConversationAware {
|
||||
fun attachConversation(handle: ConversationHandle)
|
||||
fun detachConversation(handle: ConversationHandle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальное окно в разговор, которое компонент видит через
|
||||
* [ConversationAware]. Содержит только то, что нужно большинству
|
||||
* per-conversation компонентов:
|
||||
* - идентификатор (для подписки на события),
|
||||
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
|
||||
* - последние N turns (для LlmReflector / SkillMiner).
|
||||
*
|
||||
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
|
||||
* чтобы компонент не лез в чужие обязанности.
|
||||
*/
|
||||
interface ConversationHandle : AutoCloseable {
|
||||
val id: String
|
||||
val isTemporal: Boolean
|
||||
|
||||
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
|
||||
suspend fun recentTurns(limit: Int): List<ConversationTurn>
|
||||
|
||||
override fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
|
||||
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
|
||||
* компоненты (skill mining, reflection) могли передавать его напрямую
|
||||
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
|
||||
* конвертации.
|
||||
*/
|
||||
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
|
||||
@@ -0,0 +1,78 @@
|
||||
package pw.binom.agentik.agent
|
||||
|
||||
import pw.binom.agentik.proto.Agent
|
||||
|
||||
/**
|
||||
* Настраиваемая версия [Agent]: расширяет публичный contract агента
|
||||
* install/uninstall-механикой компонентов ([Component]).
|
||||
*
|
||||
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
|
||||
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
|
||||
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
|
||||
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
|
||||
*
|
||||
* Контракт:
|
||||
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
|
||||
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
|
||||
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
|
||||
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
|
||||
*
|
||||
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
|
||||
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
|
||||
* позже, в v1 держим KISS.
|
||||
*/
|
||||
interface MutableAgent : Agent {
|
||||
/**
|
||||
* Провайдеры секций system prompt, регистрируются компонентами через [install].
|
||||
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
|
||||
* system prompt конкретной беседы; возвращает `null`, если у него нет
|
||||
* релевантной секции для данного контекста.
|
||||
*
|
||||
* Изменяется **только внутри `Component.install(this)` /
|
||||
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
|
||||
* напрямую в список не лезет.
|
||||
*/
|
||||
val systemProviders: MutableList<SystemPromptProvider>
|
||||
|
||||
/**
|
||||
* Провайдеры tools, регистрируются компонентами через [install].
|
||||
* [ToolProvider.tools] вызывается при формировании набора тулов
|
||||
* для конкретной беседы; компонент решает сам, какие тулы отдавать
|
||||
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
|
||||
*/
|
||||
val toolProviders: MutableList<ToolProvider>
|
||||
|
||||
/**
|
||||
* Устанавливает [component] в агент: `component.install(this)` +
|
||||
* агент запоминает компонент, чтобы при [close] корректно его снять.
|
||||
*
|
||||
* Возвращает `this` — для fluent-цепочек:
|
||||
* ```
|
||||
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
|
||||
* ```
|
||||
*/
|
||||
fun install(component: Component): MutableAgent
|
||||
|
||||
/**
|
||||
* Снимает [component]: `component.uninstall(this)` + забывает.
|
||||
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
|
||||
*/
|
||||
fun uninstall(component: Component): MutableAgent
|
||||
|
||||
/**
|
||||
* Оповещает все установленные компоненты, реализующие [ConversationAware],
|
||||
* о появлении нового разговора. Компонент может подписаться на события,
|
||||
* запустить фоновые задачи, проиндексировать turns и т.п.
|
||||
*/
|
||||
fun attachConversation(handle: ConversationHandle)
|
||||
|
||||
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
|
||||
fun detachConversation(handle: ConversationHandle)
|
||||
|
||||
/**
|
||||
* Освобождает ресурсы агента и снимает все установленные компоненты
|
||||
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
|
||||
* первым). Idempotent.
|
||||
*/
|
||||
override fun close()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package pw.binom.agentik.agent
|
||||
|
||||
/**
|
||||
* Провайдер одной секции system prompt конкретной беседы.
|
||||
*
|
||||
* Вызывается [MutableAgent] при каждом построении system prompt
|
||||
* (на старте беседы и после значимых изменений контекста). Возвращает
|
||||
* либо markdown-строку секции (будет вставлена в system prompt в порядке
|
||||
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
|
||||
* либо `null`, если у провайдера нет релевантной секции для данного
|
||||
* контекста (например, skill-каталог пуст).
|
||||
*
|
||||
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
|
||||
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
|
||||
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
|
||||
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
|
||||
* страх и риск, **не** рекомендуется в v1).
|
||||
*/
|
||||
fun interface SystemPromptProvider {
|
||||
|
||||
/**
|
||||
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
|
||||
*
|
||||
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
|
||||
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
|
||||
* если провайдер хочет делать per-conversation разделение, он может.
|
||||
*/
|
||||
fun getSection(conversationId: String): String
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package pw.binom.agentik.agent
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Провайдер набора тулов конкретной беседы.
|
||||
*
|
||||
* Вызывается [MutableAgent] при формировании списка тулов, доступных
|
||||
* модели в данной беседе (на старте и при пересборке после существенных
|
||||
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
|
||||
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
|
||||
* из `describe()` / `invoke()` того же объекта.
|
||||
*
|
||||
* Не-suspend: типичная реализация строит список тулов из in-memory state
|
||||
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
|
||||
* к state компонент использует свой собственный scope и кэш.
|
||||
*
|
||||
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
|
||||
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
|
||||
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
|
||||
* лишним слоем и удалён.
|
||||
*/
|
||||
fun interface ToolProvider {
|
||||
|
||||
/**
|
||||
* Возвращает список тулов, доступных модели в беседе [conversationId].
|
||||
*
|
||||
* Провайдер может делать per-conversation фильтрацию (например, скрывать
|
||||
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
|
||||
* пустой список.
|
||||
*/
|
||||
fun getTools(conversationId: String): List<LiteTool>
|
||||
}
|
||||
@@ -24,9 +24,12 @@ kotlin {
|
||||
api(project(":journal-api"))
|
||||
api(project(":reflection-api"))
|
||||
api(project(":context-api"))
|
||||
api(project(":agent-api"))
|
||||
|
||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||
api(libs.litert.api)
|
||||
// liteTool DSL (типизированные LiteTool через @Serializable args)
|
||||
api(libs.litert.tools.kotlinx.serialization)
|
||||
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.serialization.core)
|
||||
|
||||
+12
-12
@@ -1,5 +1,6 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
|
||||
*/
|
||||
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
||||
|
||||
val tool: LiteTool = syncLiteTool(
|
||||
describeJson = DESCRIBE,
|
||||
handler = ::invoke,
|
||||
)
|
||||
val tool: LiteTool = liteToolSuspend<DisableArgs>(
|
||||
name = NAME,
|
||||
description = "Deactivate a toolset by name. Its tools become unavailable.",
|
||||
) { args ->
|
||||
invoke(args)
|
||||
}
|
||||
|
||||
internal suspend fun invoke(args: String): String {
|
||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
||||
internal suspend fun invoke(args: DisableArgs): String {
|
||||
val name = args.name
|
||||
val toolset = registry.findByName(name)
|
||||
if (toolset != null) {
|
||||
// Единообразный ответ независимо от текущего состояния.
|
||||
registry.deactivate(name)
|
||||
return "Toolset '$name' deactivated."
|
||||
}
|
||||
// Неизвестный — перечисляем активные (что можно деактивировать)
|
||||
val actives = registry.activeNames()
|
||||
return if (actives.isEmpty()) {
|
||||
"Toolset '$name' not found. No toolsets to deactivate."
|
||||
@@ -39,11 +40,10 @@ class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class DisableArgs(val name: String)
|
||||
|
||||
companion object {
|
||||
const val NAME: String = "disable_toolset"
|
||||
|
||||
internal val DESCRIBE: String = """
|
||||
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
+12
-26
@@ -1,8 +1,6 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
|
||||
*/
|
||||
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
||||
|
||||
val tool: LiteTool = syncLiteTool(
|
||||
describeJson = DESCRIBE,
|
||||
handler = ::invoke,
|
||||
)
|
||||
val tool: LiteTool = liteToolSuspend<EnableArgs>(
|
||||
name = NAME,
|
||||
description = "Activate a toolset by name to access its tools.",
|
||||
) { args ->
|
||||
invoke(args)
|
||||
}
|
||||
|
||||
internal suspend fun invoke(args: String): String {
|
||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
||||
internal suspend fun invoke(args: EnableArgs): String {
|
||||
val name = args.name
|
||||
val toolset = registry.findByName(name)
|
||||
if (toolset != null) {
|
||||
val wasActive = registry.isActive(name)
|
||||
registry.activate(name)
|
||||
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
||||
}
|
||||
// Неизвестный — перечисляем доступные к активации (inactives)
|
||||
val inactives = registry.inactiveNames()
|
||||
return if (inactives.isEmpty()) {
|
||||
"Toolset '$name' not found. No toolsets available for activation."
|
||||
@@ -42,23 +41,10 @@ class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class EnableArgs(val name: String)
|
||||
|
||||
companion object {
|
||||
const val NAME: String = "enable_toolset"
|
||||
|
||||
/**
|
||||
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
|
||||
* Соответствует litert-kmp формату LiteTool.describe().
|
||||
*/
|
||||
internal val DESCRIBE: String = """
|
||||
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
|
||||
* Возвращает null если отсутствует или не строка.
|
||||
*/
|
||||
internal fun parseName(argsJson: String): String? = runCatching {
|
||||
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
|
||||
}.getOrNull()
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* (имя-как-видит-модель) → [LiteTool].
|
||||
*
|
||||
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
|
||||
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
|
||||
* чтобы избежать коллизий между разными MCP-серверами.
|
||||
*
|
||||
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
|
||||
* должен жить рядом с другими тулами в `:agent-toolsets`.
|
||||
*/
|
||||
data class NamedTool(val name: String, val tool: LiteTool)
|
||||
@@ -2,9 +2,10 @@ package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.litert.LiteTool
|
||||
import pw.binom.litert.tools.kotlinx.serialization.liteTool
|
||||
|
||||
/**
|
||||
* Адаптер из suspend-handler'а в синхронный [LiteTool].
|
||||
* Обёртка из suspend-handler'а в синхронный [LiteTool].
|
||||
*
|
||||
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
||||
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
||||
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
|
||||
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
||||
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
||||
*
|
||||
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
|
||||
* который генерит JSON-схему из `@Serializable Args` через
|
||||
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
|
||||
* тулов, чьи описания не получается выразить через `Args` (например, динамические
|
||||
* JSON Schema, приходящие со стороны).
|
||||
*
|
||||
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
||||
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
||||
*/
|
||||
internal class SyncLiteTool(
|
||||
override val name: String,
|
||||
private val describeJson: String,
|
||||
private val handler: suspend (String) -> String,
|
||||
) : LiteTool {
|
||||
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
|
||||
}
|
||||
|
||||
/**
|
||||
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
|
||||
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
|
||||
* как API-точка чтобы внешний код не зависел от internal-имени класса.
|
||||
* Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
|
||||
* JSON-схема генерится автоматически из `Args.descriptor`,
|
||||
* а сырая строка аргументов десериализуется в типизированный [Args].
|
||||
*
|
||||
* Использование:
|
||||
* ```
|
||||
* val t: LiteTool = liteToolSuspend<MyArgs>(name = "foo", description = "...") { args ->
|
||||
* suspendBlock(args) // MyArgs уже распарсен
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Реализация: под капотом используется [pw.binom.litert.tools.kotlinx.serialization.liteTool] —
|
||||
* его sync-handler запускает наш suspend-handler в [runBlocking].
|
||||
*/
|
||||
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
|
||||
SyncLiteTool(describeJson, handler)
|
||||
inline fun <reified Args> liteToolSuspend(
|
||||
name: String,
|
||||
description: String = "",
|
||||
noinline handler: suspend (Args) -> String,
|
||||
): LiteTool = liteTool<Args>(
|
||||
name = name,
|
||||
description = description,
|
||||
) { args ->
|
||||
runBlocking { handler(args) }
|
||||
}
|
||||
|
||||
@PublishedApi
|
||||
internal val invocationJson: kotlinx.serialization.json.Json = kotlinx.serialization.json.Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = false
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import pw.binom.agentik.agent.Component
|
||||
import pw.binom.agentik.agent.MutableAgent
|
||||
import pw.binom.agentik.agent.SystemPromptProvider
|
||||
import pw.binom.agentik.agent.ToolProvider
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Подключает механику toolsets к агенту:
|
||||
* - [ToolsetRegistry] (per-component instance — раньше жил в ChatAgent).
|
||||
* - Тулы [EnableToolsetTool] и [DisableToolsetTool] всегда доступны — модель
|
||||
* ими переключает состояние.
|
||||
* - Тулы активных тулсетов — динамически: после `enable_toolset(name=X)`
|
||||
* X.tools становятся видны через [ToolProvider.getTools] уже на
|
||||
* следующем turn'е.
|
||||
* - Секция системного промпта — список активных/неактивных тулсетов,
|
||||
* чтобы модель знала что включено.
|
||||
*
|
||||
* Один [ToolsetComponent] на агента. Шарится между беседами через общий
|
||||
* [MutableAgent] (все conversations читают один [ToolsetRegistry]).
|
||||
*
|
||||
* `install(agent)` идемпотентно. `uninstall(agent)` снимает оба провайдера
|
||||
* по типу (см. [ToolsetToolProvider], [ToolsetSystemProvider]).
|
||||
*/
|
||||
class ToolsetComponent(
|
||||
private val contributions: List<ToolsetContribution>,
|
||||
) : Component {
|
||||
|
||||
/**
|
||||
* Реестр тулсетов, владеет [ToolsetComponent]. `private` — наружу не светится,
|
||||
* чтобы никто не дёргал его мимо `enable_toolset`/`disable_toolset` тулов.
|
||||
*/
|
||||
private val registry: ToolsetRegistry = ToolsetRegistry(contributions)
|
||||
|
||||
private var provider: ToolsetToolProvider? = null
|
||||
|
||||
override fun install(agent: MutableAgent) {
|
||||
val p = ToolsetToolProvider(registry, contributions)
|
||||
agent.toolProviders.add(p)
|
||||
provider = p
|
||||
agent.systemProviders.add(ToolsetSystemProvider(registry))
|
||||
}
|
||||
|
||||
override fun uninstall(agent: MutableAgent) {
|
||||
provider?.let { agent.toolProviders.remove(it) }
|
||||
agent.systemProviders.removeAll { it is ToolsetSystemProvider }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает тулсет-тулы в зависимости от текущего состояния реестра:
|
||||
* - `enable_toolset` / `disable_toolset` — всегда.
|
||||
* - Тулы активных тулсетов — те, что перечислены в [ToolsetRegistry.activeNames].
|
||||
*
|
||||
* Snapshot собирается на каждом вызове [getTools] — диспетчер видит свежее
|
||||
* состояние после `enable_toolset` уже на следующем turn'е.
|
||||
*/
|
||||
class ToolsetToolProvider(
|
||||
private val registry: ToolsetRegistry,
|
||||
private val contributions: List<ToolsetContribution>,
|
||||
) : ToolProvider {
|
||||
|
||||
override fun getTools(conversationId: String): List<LiteTool> = buildList {
|
||||
add(EnableToolsetTool(registry).tool)
|
||||
add(DisableToolsetTool(registry).tool)
|
||||
// Активные тулсеты — добавляем их тулы в общий пул. Это синхронная
|
||||
// версия (lock-free snapshot), потому что `getTools` вызывается
|
||||
// синхронно из `collectTools()`; `active` сам по себе Concurrent-Set
|
||||
// через Mutex в реестре (все мутации — через activate/deactivate).
|
||||
val active = runBlockingSnapshot()
|
||||
contributions.filter { it.name in active }.forEach { c ->
|
||||
c.tools.forEach { add(it.tool) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает снимок активных имён без suspend-блокировки.
|
||||
* ToolsetRegistry.activeNames() — suspend, но его можно обойти если
|
||||
* вычислить через прямой snapshot — для простоты используем runBlocking.
|
||||
* Это всё равно вызывается на каждый turn, но мьютекс короткий.
|
||||
*/
|
||||
private fun runBlockingSnapshot(): Set<String> = kotlinx.coroutines.runBlocking {
|
||||
registry.activeNames().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Секция системного промпта с описанием доступных тулсетов:
|
||||
* - `*active*` — что уже подключено.
|
||||
* - `*inactive*` — что доступно через `enable_toolset`.
|
||||
*/
|
||||
class ToolsetSystemProvider(
|
||||
private val registry: ToolsetRegistry,
|
||||
) : SystemPromptProvider {
|
||||
override fun getSection(conversationId: String): String {
|
||||
val activeNames = kotlinx.coroutines.runBlocking { registry.activeNames() }.toSet()
|
||||
val all = registry.all()
|
||||
val active = all.filter { it.name in activeNames }
|
||||
val inactive = all.filter { it.name !in activeNames }
|
||||
return SystemPromptToolsetSection.render(active = active, inactive = inactive) ?: ""
|
||||
}
|
||||
}
|
||||
+5
-12
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
|
||||
class DisableToolsetToolTest {
|
||||
|
||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||
override val name: String = name
|
||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = "ok"
|
||||
}
|
||||
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
|
||||
ToolsetContribution("media", "media tools", emptyList()),
|
||||
))
|
||||
reg.activate("media")
|
||||
val r = disable.invoke("""{"name":"media"}""")
|
||||
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||
assertEquals("Toolset 'media' deactivated.", r)
|
||||
assertEquals(false, reg.isActive("media"))
|
||||
}
|
||||
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
|
||||
val (disable, _) = harness(listOf(
|
||||
ToolsetContribution("media", "media tools", emptyList()),
|
||||
))
|
||||
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
|
||||
val r = disable.invoke("""{"name":"media"}""")
|
||||
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||
assertEquals("Toolset 'media' deactivated.", r)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
|
||||
))
|
||||
reg.activate("a")
|
||||
reg.activate("b")
|
||||
val r = disable.invoke("""{"name":"unknown"}""")
|
||||
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
|
||||
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
||||
}
|
||||
|
||||
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
|
||||
val (disable, _) = harness(listOf(
|
||||
ToolsetContribution("a", "x", emptyList()),
|
||||
))
|
||||
val r = disable.invoke("""{"name":"unknown"}""")
|
||||
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
|
||||
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing name argument returns error message`() = runTest {
|
||||
val (disable, _) = harness(emptyList())
|
||||
val r = disable.invoke("""{}""")
|
||||
assertEquals("missing required argument 'name'", r)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-11
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
|
||||
class EnableToolsetToolTest {
|
||||
|
||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||
override val name: String = name
|
||||
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = "ok"
|
||||
}
|
||||
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
|
||||
val (enable, reg) = harness(listOf(
|
||||
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
||||
))
|
||||
val r = enable.invoke("""{"name":"media"}""")
|
||||
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
|
||||
assertEquals("Toolset 'media' activated.", r)
|
||||
assertEquals(true, reg.isActive("media"))
|
||||
}
|
||||
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
|
||||
ToolsetContribution("media", "media tools", emptyList()),
|
||||
))
|
||||
reg.activate("media")
|
||||
val r = enable.invoke("""{"name":"media"}""")
|
||||
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
|
||||
assertEquals("Toolset 'media' already active.", r)
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
|
||||
ToolsetContribution("b", "y", emptyList()),
|
||||
ToolsetContribution("c", "z", emptyList()),
|
||||
))
|
||||
val r = enable.invoke("""{"name":"unknown"}""")
|
||||
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
|
||||
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
||||
}
|
||||
|
||||
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
|
||||
ToolsetContribution("a", "x", emptyList()),
|
||||
))
|
||||
reg.activate("a")
|
||||
val r = enable.invoke("""{"name":"unknown"}""")
|
||||
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
|
||||
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing name argument returns error message`() = runTest {
|
||||
val (enable, _) = harness(emptyList())
|
||||
val r = enable.invoke("""{}""")
|
||||
assertEquals("missing required argument 'name'", r)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ import kotlin.test.assertTrue
|
||||
class ToolsetDispatchPolicyTest {
|
||||
|
||||
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
||||
override val name: String = name
|
||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = response
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
|
||||
class ToolsetRegistryTest {
|
||||
|
||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
||||
override val name: String = name
|
||||
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||
override fun invoke(arguments: String) = "ok:$name"
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
||||
// Подписываемся на поток событий ДО send: события, отправленные
|
||||
// до подписки, не реплеятся (shared-flow без replay).
|
||||
val eventsJob = launch {
|
||||
conv.events(Instant.DISTANT_PAST)
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||
// onEach печатает и терминальный event, takeWhile лишь
|
||||
// завершает сбор после него.
|
||||
.map { it.event }
|
||||
.onEach { ev -> emit(ev) }
|
||||
.takeWhile { ev -> !isTerminal(ev) }
|
||||
.collect { }
|
||||
@@ -53,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
||||
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||
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.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.Interrupted -> println("event Interrupted")
|
||||
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.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -92,12 +92,12 @@ internal class TuiBackend(
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывается на [Conversation.events] и перенаправляет их в [state].
|
||||
* Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
|
||||
*/
|
||||
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||
eventsJob?.cancel()
|
||||
eventsJob = scope.launch {
|
||||
conv.events(from).collect { ev -> dispatch(ev) }
|
||||
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ package pw.binom.agentik.tui
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.Content
|
||||
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.MessageContext
|
||||
import kotlin.time.Instant
|
||||
@@ -31,10 +32,13 @@ internal class FakeAgent(
|
||||
// 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.proto.CommonEvent>()
|
||||
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
|
||||
override fun agentEvents(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
|
||||
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
|
||||
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||
override fun close() {}
|
||||
}
|
||||
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests")
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation {
|
||||
createCount++
|
||||
@@ -49,8 +53,7 @@ internal class FakeAgent(
|
||||
override suspend fun deleteConversation(id: String): Boolean =
|
||||
conversations.removeAll { it.id == id }
|
||||
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
||||
conversations.toList()
|
||||
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
@@ -170,7 +170,7 @@ class TuiBackendTest {
|
||||
runCurrent()
|
||||
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.ToolResult(date = now, id = "1", result = "ok"))
|
||||
conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
|
||||
runCurrent()
|
||||
|
||||
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||
|
||||
+2
-2
@@ -47,8 +47,8 @@ val moduleDescriptions: Map<String, String> = mapOf(
|
||||
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
||||
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
||||
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
||||
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
|
||||
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
||||
"storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
|
||||
"storage-sqlite" to "agentik :storage-sqlite — исторический модуль (deleted 2026-09-22; ksqlite-реализации теперь живут в :journal-ksqlite / :context-ksqlite / :reflection-ksqlite).",
|
||||
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
||||
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
||||
|
||||
+192
-4
@@ -16,6 +16,11 @@
|
||||
- `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`) — статус НЕ мешается
|
||||
с основным потоком событий. См. ниже.
|
||||
|
||||
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
|
||||
@@ -36,6 +41,30 @@ dependencies {
|
||||
}
|
||||
```
|
||||
|
||||
## Что клиент хранит локально (persistence)
|
||||
|
||||
Либа **не** имеет `SettingsRepository` / `Config` — это намеренно: UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain, Android DataStore, NSUserDefaults, ...). Либа не навязывает формат, но клиент должен сериализовать у себя минимум:
|
||||
|
||||
| Поле | Что это | Где взять |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
Опционально (для 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 пример: создаём агента, открываем диалог,
|
||||
@@ -256,6 +285,55 @@ session.scope.launch {
|
||||
`rec is MessageRecord.UserMessage` для реплик пользователя,
|
||||
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
|
||||
|
||||
## Кэш списка бесед
|
||||
|
||||
`agent.conversationStore` — read-only view поверх `conversation`-таблицы
|
||||
на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
|
||||
updatedAt, без `Conversation` handle и без флагов image-support).
|
||||
|
||||
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы
|
||||
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
|
||||
моментально реагировал на создание/удаление/переименование в другой
|
||||
вкладке.
|
||||
|
||||
Подход — тот же **«remote → local snapshot + live-events»**:
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.client.AgentikAgent
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
|
||||
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш:
|
||||
// remote.listFlow → local.upsert (snapshot)
|
||||
// outbox.agentEvents → local.upsert / delete (live)
|
||||
val agent = AgentikAgent(
|
||||
id = "agentik",
|
||||
baseUrl = "http://localhost:8080/agentik",
|
||||
engineFactory = CIO,
|
||||
)
|
||||
|
||||
// Кэш уже наполняется в фоне, читать можно сразу:
|
||||
val all = agent.conversationStore.list(0, Int.MAX_VALUE)
|
||||
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") }
|
||||
|
||||
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched)
|
||||
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev ->
|
||||
when (ev) {
|
||||
is AgentEvent.Created -> println("+ ${ev.conversationId}")
|
||||
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}")
|
||||
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})")
|
||||
is AgentEvent.Deleted -> println("- ${ev.id}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP
|
||||
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как
|
||||
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
|
||||
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
|
||||
если нужен SQLite/JSON-store.
|
||||
|
||||
## Стриминг live-ответа
|
||||
|
||||
Для streaming-рендера текущего хода подписывайся на `events()` и
|
||||
@@ -308,13 +386,81 @@ UI-обновление списка — отдельная задача, реш
|
||||
|
||||
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
|
||||
отдаёт типы и потоки.
|
||||
- **Персистентность кэша** — `InMemoryJournalStore` хранит в RAM. Для
|
||||
диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в
|
||||
`:journal-ksqlite` как образец).
|
||||
- **Персистентность кэша** — `InMemoryJournalStore` и
|
||||
`InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
|
||||
`MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
|
||||
в `:journal-ksqlite` как образец).
|
||||
- **Нестандартные движковые настройки** — для `requestTimeout`,
|
||||
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
|
||||
напрямую.
|
||||
|
||||
## Кэш списка бесед
|
||||
|
||||
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
|
||||
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
|
||||
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
|
||||
|
||||
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
|
||||
в `localStore.upsert(...)`.
|
||||
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
|
||||
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
|
||||
- `Deleted(id)` → `local.delete(id)`
|
||||
- `Renamed(id, title)` → `local.rename(id, title)`
|
||||
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
|
||||
|
||||
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
|
||||
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
|
||||
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
|
||||
клиент видит их через SSE и применяет к локальной копии.
|
||||
|
||||
**Команды** (создать / переименовать / удалить) идут через `agent`:
|
||||
|
||||
```kotlin
|
||||
// Создать новую беседу:
|
||||
val conv = agent.createConversation(temp = false) // → POST /conversations
|
||||
// → server эмитит Created
|
||||
// → client cache получает Created
|
||||
// → UI увидит её в списке
|
||||
// Переименовать:
|
||||
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
|
||||
// → server эмитит Renamed
|
||||
// → client cache обновляет title
|
||||
// Удалить:
|
||||
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
|
||||
// → server эмитит Deleted
|
||||
// → client cache удаляет запись
|
||||
```
|
||||
|
||||
`conversationStore` доступен **только для чтения**. Это read-only projection
|
||||
на серверную таблицу `conversation` (id + title + timestamps). Для активной
|
||||
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
|
||||
|
||||
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
|
||||
командами `agent.createConversation / deleteConversation / renameConversation`.
|
||||
|
||||
### Если хочется своего cache-импла
|
||||
|
||||
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
|
||||
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
|
||||
после перезапуска) — реализуй свой `MutableConversationStore` поверх
|
||||
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
|
||||
`:journal-ksqlite` как образец.
|
||||
|
||||
```kotlin
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
|
||||
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
|
||||
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
|
||||
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
|
||||
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
|
||||
override suspend fun delete(id: String): Boolean { /* DELETE */ }
|
||||
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
|
||||
override suspend fun touch(id: String, now: Instant) { /* UPDATE updatedAt */ }
|
||||
override fun close() {}
|
||||
}
|
||||
```
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
@@ -322,7 +468,49 @@ UI-обновление списка — отдельная задача, реш
|
||||
```
|
||||
|
||||
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||
401/404.
|
||||
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
|
||||
reconnect / exhausted attempts → Failed / close → cancel).
|
||||
|
||||
## Auto-reconnect для живого outbox
|
||||
|
||||
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
|
||||
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
|
||||
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
|
||||
|
||||
```kotlin
|
||||
val recon = ReconnectingOutbox(
|
||||
outbox = agent.outbox, // или HttpEventStore
|
||||
scope = myScreenScope,
|
||||
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
|
||||
)
|
||||
|
||||
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
|
||||
scope.launch {
|
||||
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)`.
|
||||
|
||||
## Известное ограничение
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ kotlin {
|
||||
api(project(":proto"))
|
||||
api(project(":outbox-api"))
|
||||
api(project(":journal-api"))
|
||||
implementation(project(":journal-inmemory"))
|
||||
|
||||
api(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
|
||||
@@ -5,25 +5,28 @@ import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||
*
|
||||
* HttpClient создаётся внутри из переданного engine и закрывается в [close].
|
||||
*
|
||||
* **Storage handles** ([journal], [outbox]) — read-only views на серверные
|
||||
* хранилища.
|
||||
* **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
|
||||
* views на серверные хранилища. Запись — только через команды
|
||||
* [createConversation] / [deleteConversation] / [renameConversation].
|
||||
*/
|
||||
internal class AgentClient(
|
||||
override val id: String,
|
||||
@@ -35,6 +38,7 @@ internal class AgentClient(
|
||||
|
||||
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||
|
||||
override fun createConversation(temp: Boolean): Conversation =
|
||||
runBlocking {
|
||||
@@ -53,16 +57,18 @@ internal class AgentClient(
|
||||
}
|
||||
|
||||
override suspend fun deleteConversation(id: String): Boolean {
|
||||
val response: HttpResponse = httpClient.delete("$agentUrl/conversations/$id")
|
||||
val response = httpClient.delete("$agentUrl/conversations/$id")
|
||||
return response.status == HttpStatusCode.NoContent
|
||||
}
|
||||
|
||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
|
||||
val snapshots = httpClient.get("$agentUrl/conversations") {
|
||||
parameter("offset", offset)
|
||||
parameter("limit", limit)
|
||||
}.body<List<ConversationSnapshot>>()
|
||||
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
||||
override suspend fun renameConversation(id: String, title: String?): Instant? {
|
||||
val response = httpClient.patch("$agentUrl/conversations/$id") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(RequestRename(title))
|
||||
}
|
||||
if (response.status == HttpStatusCode.NotFound) return null
|
||||
val rec = response.body<pw.binom.agentik.journal.ConversationRecord>()
|
||||
return rec.updatedAt
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
package pw.binom.agentik.client
|
||||
|
||||
import io.ktor.client.engine.HttpClientEngineFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||
@@ -20,24 +33,141 @@ import pw.binom.agentik.proto.Agent
|
||||
* )
|
||||
* val conv = agent.createConversation(temp = false)
|
||||
* conv.send(listOf(Content.Text("hi")))
|
||||
* conv.events(Instant.DISTANT_PAST).collect { ... }
|
||||
* agent.close() // закрывает HttpClient
|
||||
* agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||
* .map { it.event }
|
||||
* .collect { ... }
|
||||
* agent.close() // закрывает HttpClient + локальный кэш
|
||||
* ```
|
||||
*
|
||||
* [id] пробрасывается в `Agent.id` — сервер про идентичность агента не знает,
|
||||
* поэтому клиент должен её знать сам (или взять из конфига).
|
||||
* ## Что клиент должен хранить локально (persistence)
|
||||
*
|
||||
* Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
|
||||
* UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain,
|
||||
* `SharedPreferences`, Android DataStore, NSUserDefaults, ...). Либа
|
||||
* не навязывает формат, но вот минимальный набор, который клиент должен
|
||||
* сериализовать у себя, чтобы пережить перезапуск:
|
||||
*
|
||||
* | Поле | Что это | Где взять |
|
||||
* |---|---|---|
|
||||
* | `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 на сервере.
|
||||
*
|
||||
* ## Локальный кэш списка бесед
|
||||
*
|
||||
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
|
||||
* Внутри лежит [InMemoryMutableConversationStore], который:
|
||||
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`.
|
||||
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого
|
||||
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
|
||||
* соответствующий `upsert/delete/rename/touch` к локальной копии.
|
||||
*
|
||||
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
|
||||
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
|
||||
* через [Agent] и **не** через `conversationStore` (он read-only).
|
||||
*
|
||||
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
||||
* HttpClient (идемпотентно). После этого `createConversation` /
|
||||
* `getConversation` etc. не определены.
|
||||
* HttpClient + локальный кэш + background-coroutine (идемпотентно).
|
||||
* После этого `createConversation` / `getConversation` etc. не определены.
|
||||
*/
|
||||
fun AgentikAgent(
|
||||
id: String,
|
||||
baseUrl: String,
|
||||
engineFactory: HttpClientEngineFactory<*>,
|
||||
token: String? = null,
|
||||
): Agent = AgentClient(
|
||||
id = id,
|
||||
baseUrl = baseUrl,
|
||||
httpClient = agentikHttpClient(engineFactory = engineFactory, token = token),
|
||||
)
|
||||
): Agent {
|
||||
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
|
||||
val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient)
|
||||
return wrapWithLocalConversationCache(client, scopeClient = client)
|
||||
}
|
||||
|
||||
/**
|
||||
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
|
||||
* локальным in-memory кэшем, синхронизированным с удалённым стором
|
||||
* через outbox-события.
|
||||
*
|
||||
* - **Seed**: при создании делает один snapshot через
|
||||
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
|
||||
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
|
||||
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
|
||||
*
|
||||
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
|
||||
* (на read-only projection локального [InMemoryMutableConversationStore]).
|
||||
* Остальные методы [Agent] — delegated в [delegate].
|
||||
*/
|
||||
private fun wrapWithLocalConversationCache(
|
||||
delegate: Agent,
|
||||
scopeClient: Agent,
|
||||
): Agent = object : Agent by delegate {
|
||||
|
||||
private val localStore: MutableConversationStore = InMemoryMutableConversationStore()
|
||||
private val cacheScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val syncJob: Job
|
||||
|
||||
init {
|
||||
// Делаем cacheStore read-only view на localStore.
|
||||
// (Через вложенный класс — см. ниже.)
|
||||
// Запускаем seed + live-refresh параллельно.
|
||||
syncJob = cacheScope.launch {
|
||||
// 1. seed — snapshot всех текущих бесед с сервера
|
||||
try {
|
||||
delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE)
|
||||
.collect { rec -> localStore.upsert(rec) }
|
||||
} catch (_: Throwable) {
|
||||
// seed может упасть (offline / 5xx) — не критично,
|
||||
// live-источник всё равно догонит при первом событии.
|
||||
}
|
||||
|
||||
// 2. live — применяем outbox-события.
|
||||
// Используем `first()` для knownId после Created — потом отписываемся,
|
||||
// потому что Created нужно вытянуть полный record через `remote.get(id)`.
|
||||
// Renamed/Touched меняют локальную копию без round-trip.
|
||||
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce ->
|
||||
when (val ev = ce.event) {
|
||||
is AgentEvent.Created -> {
|
||||
// Created не несёт title/timestamps — нужно сходить в remote.
|
||||
val rec = delegate.conversationStore.get(ev.conversationId)
|
||||
if (rec != null) localStore.upsert(rec)
|
||||
}
|
||||
is AgentEvent.Deleted -> localStore.delete(ev.id)
|
||||
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
|
||||
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only projection локального кэша — клиент через него только
|
||||
* читает (`get` / `list` / `listFlow`).
|
||||
*/
|
||||
override val conversationStore: ConversationStore = object : ConversationStore {
|
||||
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
|
||||
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
|
||||
override fun close() {} // owned by outer close
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cacheScope.cancel()
|
||||
runBlocking { syncJob.join() }
|
||||
delegate.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,12 @@ import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import kotlin.time.Instant
|
||||
@@ -72,22 +66,6 @@ internal class ConversationClient(
|
||||
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> =
|
||||
httpClient.get("$convUrl/messages") {
|
||||
parameter("after", after.toString())
|
||||
|
||||
@@ -23,4 +23,4 @@ data class ConversationSnapshot(
|
||||
internal data class RequestCreateConversation(val temp: Boolean)
|
||||
|
||||
@Serializable
|
||||
internal data class RequestRename(val title: String)
|
||||
internal data class RequestRename(val title: String?)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
|
||||
/**
|
||||
* HTTP-реализация [ConversationStore] (read-only metadata view),
|
||||
* ходящая в `:server`-фасад.
|
||||
*
|
||||
* **Endpoint**: `GET {baseUrl}/conversations?offset=&limit=` —
|
||||
* возвращает `List<ConversationRecord>` (id, title, isTemporal, createdAt,
|
||||
* updatedAt) БЕЗ handle'ов и image-support флагов (это лёгкая проекция
|
||||
* для UI-списка; handle берётся через `agent.getConversation(id)`).
|
||||
*
|
||||
* **Read-only**: запись в `conversation` table — только через команды
|
||||
* `agent.createConversation / deleteConversation / renameConversation`.
|
||||
*
|
||||
* Клиентский кэш строится композицией `HttpConversationStore` (snapshot)
|
||||
* + `agent.outbox.agentEvents(after)` (live deltas: Created/Deleted/
|
||||
* Renamed/Touched) — см. `client/README.md` секция
|
||||
* «Кэш списка бесед».
|
||||
*/
|
||||
internal class HttpConversationStore(
|
||||
private val httpClient: HttpClient,
|
||||
private val baseUrl: String,
|
||||
) : ConversationStore {
|
||||
|
||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
override suspend fun get(id: String): ConversationRecord? {
|
||||
val response = httpClient.get("$agentUrl/conversations/$id")
|
||||
if (response.status == HttpStatusCode.NotFound) return null
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"conversationStore.get($id): server returned ${response.status}"
|
||||
}
|
||||
return response.body<ConversationRecord>()
|
||||
}
|
||||
|
||||
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> {
|
||||
val response = httpClient.get("$agentUrl/conversations") {
|
||||
parameter("offset", offset)
|
||||
parameter("limit", limit)
|
||||
}
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"conversationStore.list: server returned ${response.status}"
|
||||
}
|
||||
return response.body<List<ConversationRecord>>()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ plugins {
|
||||
// агента.
|
||||
//
|
||||
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||
// на Linux (см. KDoc :storage-ksqlite).
|
||||
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
@@ -21,7 +21,9 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT")
|
||||
// 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"))
|
||||
|
||||
+39
-9
@@ -17,20 +17,41 @@ import kotlinx.coroutines.withContext
|
||||
/**
|
||||
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
|
||||
*
|
||||
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
|
||||
* из `:storage-ksqlite`, но:
|
||||
* - лежит в собственном модуле `:context-ksqlite`;
|
||||
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
|
||||
* теперь главный класс — `ContextStore`); сами типы строк
|
||||
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
|
||||
* Единственный владелец таблицы `working_memory` в проекте. Используется
|
||||
* напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
|
||||
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
|
||||
*
|
||||
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
|
||||
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Семантика владения connection'ом идентична
|
||||
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
|
||||
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
|
||||
* закрывает его в [close].
|
||||
* - `KsqliteContextStore(path)` — открывает файловое соединение,
|
||||
* закрывает его в [close].
|
||||
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
|
||||
*
|
||||
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
|
||||
*/
|
||||
class KsqliteContextStore(
|
||||
class KsqliteContextStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : ContextStore {
|
||||
|
||||
constructor(path: String) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
)
|
||||
|
||||
constructor(connection: SQLiteConnection) : this(
|
||||
connection = connection,
|
||||
ownsConnection = false,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -179,6 +200,15 @@ class KsqliteContextStore(
|
||||
maxOrderIdxStmt.close()
|
||||
dropFromIdxStmt.close()
|
||||
insertSummaryStmt.close()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun memory(name: String? = null): KsqliteContextStore =
|
||||
KsqliteContextStore(
|
||||
connection = SQLiteConnection.memory(name),
|
||||
ownsConnection = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun maxOrderIdx(conversationId: String): Long {
|
||||
|
||||
@@ -12,7 +12,7 @@ import pw.binom.db.ksqlite.SQLiteConnection
|
||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||
*/
|
||||
internal object Schema {
|
||||
object Schema {
|
||||
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
|
||||
+3
-11
@@ -12,16 +12,9 @@ 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` — этот модуль
|
||||
* автономный.
|
||||
* Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
|
||||
* SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
|
||||
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||
*/
|
||||
class KsqliteContextStoreTest {
|
||||
|
||||
@@ -31,7 +24,6 @@ class KsqliteContextStoreTest {
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
|
||||
Schema.migrate(conn)
|
||||
store = KsqliteContextStore(conn)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
|
||||
|
||||
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
|
||||
|
||||
### `ConversationStore`
|
||||
### `MutableConversationStore`
|
||||
|
||||
```kotlin
|
||||
suspend fun upsert(record: ConversationRecord)
|
||||
|
||||
@@ -6,11 +6,11 @@ kotlinx-io = "0.8.0"
|
||||
ktor = "3.1.3"
|
||||
a2a = "1.0.0-SNAPSHOT"
|
||||
kaml = "0.104.0"
|
||||
litert = "8"
|
||||
litert = "13"
|
||||
sqldelight = "2.3.2"
|
||||
shadow = "8.3.5"
|
||||
jvector = "3.0.6"
|
||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
||||
text-embedding-kmp = "5"
|
||||
kotlin-logging = "3.0.5"
|
||||
logback = "1.5.18"
|
||||
mosaic = "0.18.0"
|
||||
@@ -40,6 +40,9 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
|
||||
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
||||
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
|
||||
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
|
||||
# liteTool(liteToolRaw) DSL: типизированные LiteTool через @Serializable args.
|
||||
# https://git.binom.pw/subochev/litert-kmp/src/branch/main/litert-tools-kotlinx-serialization
|
||||
litert-tools-kotlinx-serialization = { module = "pw.binom.litert:litert-tools-kotlinx-serialization", version.ref = "litert" }
|
||||
|
||||
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||
@@ -97,10 +100,18 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
|
||||
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||
|
||||
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
|
||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
|
||||
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
|
||||
# (с 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" }
|
||||
|
||||
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" }
|
||||
# KMP-артефакт (он же `kotlin-logging-jvm` существует отдельно как JVM-only build).
|
||||
kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" }
|
||||
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Snapshot диалога. В таблице `conversation` хранится как есть.
|
||||
*/
|
||||
data class ConversationRecord(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val isTemporal: Boolean,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
* Read-only view of the `conversation` table (CRUD-операции находятся
|
||||
* в [MutableConversationStore] и используются только внутри ChatAgent).
|
||||
*
|
||||
* Клиенты видят [ConversationStore] через [pw.binom.agentik.proto.Agent.conversationStore]
|
||||
* (по аналогии с `journal` / `outbox`) и строят свой локальный кэш:
|
||||
* - **seed** через [list] (snapshot страницы) или [listFlow] (cold-flow paging);
|
||||
* - **live-refresh** через `outbox.agentEvents()` — Created / Deleted /
|
||||
* Renamed / Touched.
|
||||
*
|
||||
* Запись в хранилище **не** делается клиентом — только команды
|
||||
* `agent.createConversation / deleteConversation / renameConversation`.
|
||||
*/
|
||||
interface ConversationStore : AutoCloseable {
|
||||
|
||||
/** Диалог по id, или `null`. */
|
||||
suspend fun get(id: String): ConversationRecord?
|
||||
|
||||
/** Список диалогов, отсортированный по `updatedAt` DESC. */
|
||||
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
|
||||
|
||||
/**
|
||||
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
||||
* (по странице через `list()` пока не получит короткую страницу). Для
|
||||
* HTTP-импл — это лишние round-trip'ы; реализация может переопределить.
|
||||
*/
|
||||
fun listFlow(offset: Int = 0, pageSize: Int = PAGE_SIZE): Flow<ConversationRecord> = flow {
|
||||
var skip = offset
|
||||
while (true) {
|
||||
val page = list(skip, pageSize)
|
||||
if (page.isEmpty()) break
|
||||
page.forEach { emit(it) }
|
||||
skip += page.size
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE: Int = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Генератор id. Использует `kotlin.uuid.Uuid` из stdlib (KMP: jvm + native),
|
||||
* чтобы не зависеть от `java.util.UUID` и подготовить код к linuxX64-сборке.
|
||||
*
|
||||
* Сохраняет формат `<prefix>-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` —
|
||||
* `:server` его парсит как opaque string, без знания внутренней структуры.
|
||||
*/
|
||||
object Ids {
|
||||
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
|
||||
}
|
||||
@@ -55,6 +55,14 @@ sealed interface MessageRecord {
|
||||
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
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* CRUD по таблице `conversation`.
|
||||
*/
|
||||
interface MutableConversationStore : ConversationStore {
|
||||
|
||||
/** Создать или обновить snapshot диалога. */
|
||||
suspend fun upsert(record: ConversationRecord)
|
||||
|
||||
/** Удалить диалог (вместе с его сообщениями и working memory). */
|
||||
suspend fun delete(id: String): Boolean
|
||||
|
||||
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
|
||||
suspend fun rename(id: String, title: String?): Instant?
|
||||
|
||||
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
|
||||
suspend fun touch(id: String, now: Instant)
|
||||
}
|
||||
@@ -14,4 +14,12 @@ package pw.binom.agentik.journal
|
||||
*/
|
||||
interface MutableJournalStore : JournalStore {
|
||||
suspend fun append(record: MessageRecord)
|
||||
|
||||
/**
|
||||
* Удалить все сообщения диалога [conversationId]. Используется
|
||||
* владельцем lifecycle диалога при его удалении (каскад из
|
||||
* ChatAgent.deleteConversation). Append-only природа audit log'а
|
||||
* не нарушается — это bulk-clear, а не редактирование.
|
||||
*/
|
||||
suspend fun clear(conversationId: String)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-реализация [MutableJournalStore] на `MutableList` + `Mutex` — для
|
||||
// тестов, dev-режима, embedded-сценариев (Android core, CLI, in-process кэш
|
||||
// в клиенте) и как образец для своей реализации.
|
||||
//
|
||||
// `list` фильтрует по `conversationId`+`createdAt>after` и сортирует
|
||||
// по `createdAt ASC`. Paging — поверх отфильтрованного списка.
|
||||
// KMP-реализация [MutableJournalStore] и [MutableConversationStore] на
|
||||
// `MutableList`/`MutableMap` + `Mutex` — для тестов, dev-режима,
|
||||
// embedded-сценариев (Android core, CLI, in-process кэш в клиенте) и как
|
||||
// образец для своей реализации.
|
||||
//
|
||||
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
|
||||
|
||||
@@ -15,7 +13,13 @@ kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
|
||||
+3
-3
@@ -54,9 +54,9 @@ class InMemoryJournalStore : MutableJournalStore {
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Сбросить кэш (например, когда диалог удалён). */
|
||||
suspend fun clear(): Unit = mutex.withLock {
|
||||
records.clear()
|
||||
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
|
||||
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
|
||||
records.removeAll { it.conversationId == conversationId }
|
||||
}
|
||||
|
||||
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
||||
|
||||
+18
-7
@@ -1,24 +1,35 @@
|
||||
package pw.binom.agentik.storage.inmemory
|
||||
package pw.binom.agentik.journal.inmemory
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.time.Clock
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Thread-safe Map-импл [ConversationStore].
|
||||
* Thread-safe Map-импл [MutableConversationStore] для клиентских
|
||||
* in-process кэшей (и тестов/dev-режима).
|
||||
*
|
||||
* Использует `Mutex` для атомарности read-modify-write операций
|
||||
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления
|
||||
* (lost-update race), что в SQLite невозможно из-за driver-level locking.
|
||||
*
|
||||
* **Сортировка**: `list()` сортирует по `updatedAt DESC`.
|
||||
*
|
||||
* **Типичный кэш-паттерн в клиенте** (см. `client/README.md`):
|
||||
* ```
|
||||
* val local = InMemoryMutableConversationStore()
|
||||
* // seed: remote.listFlow → local.upsert
|
||||
* // live-refresh: outbox.agentEvents → local.upsert/delete/rename/touch
|
||||
* // UI: local.list(0, PAGE_SIZE)
|
||||
* ```
|
||||
*/
|
||||
class InMemoryConversationStore(
|
||||
class InMemoryMutableConversationStore(
|
||||
private val clock: Clock = Clock.System,
|
||||
) : ConversationStore {
|
||||
) : MutableConversationStore {
|
||||
|
||||
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf()
|
||||
private val byId = mutableMapOf<String, ConversationRecord>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
override suspend fun upsert(record: ConversationRecord) {
|
||||
+5
-3
@@ -66,13 +66,15 @@ class InMemoryJournalStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties the cache`() = runTest {
|
||||
fun `clear empties a conversation only`() = runTest {
|
||||
val store = InMemoryJournalStore()
|
||||
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||
store.append(userMsg("m1", "c1", "x", t0))
|
||||
store.append(userMsg("m2", "c2", "y", t0))
|
||||
assertEquals(2, store.size())
|
||||
store.clear("c1")
|
||||
assertEquals(1, store.size())
|
||||
store.clear()
|
||||
assertEquals(0, store.size())
|
||||
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
|
||||
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
package pw.binom.agentik.storage.inmemory
|
||||
package pw.binom.agentik.journal.inmemory
|
||||
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import kotlin.test.Test
|
||||
@@ -9,11 +9,11 @@ import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
class InMemoryConversationStoreTest {
|
||||
class InMemoryMutableConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `upsert and get roundtrip preserves all fields`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
val rec = ConversationRecord(
|
||||
id = "c1",
|
||||
title = "test",
|
||||
@@ -28,13 +28,13 @@ class InMemoryConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `get returns null for missing id`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
assertNull(store.get("nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete removes the record and returns true`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
store.upsert(
|
||||
ConversationRecord(
|
||||
"c1", null, false,
|
||||
@@ -50,7 +50,7 @@ class InMemoryConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
|
||||
@@ -69,7 +69,7 @@ class InMemoryConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||
|
||||
@@ -84,7 +84,7 @@ class InMemoryConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `rename with null title clears it`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.upsert(ConversationRecord("c1", "old", false, t0, t0))
|
||||
store.rename("c1", null)
|
||||
@@ -93,13 +93,13 @@ class InMemoryConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `rename returns null for missing conversation`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
assertNull(store.rename("nope", "x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `touch bumps updatedAt without changing other fields`() = runTest {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
|
||||
@@ -112,7 +112,7 @@ class InMemoryConversationStoreTest {
|
||||
|
||||
@Test
|
||||
fun `close is idempotent and does nothing`() {
|
||||
val store = InMemoryConversationStore()
|
||||
val store = InMemoryMutableConversationStore()
|
||||
store.close()
|
||||
store.close() // должно быть no-op
|
||||
}
|
||||
@@ -9,7 +9,7 @@ plugins {
|
||||
// собственных ksqlite-модулях.
|
||||
//
|
||||
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||
// на Linux (см. KDoc :storage-ksqlite).
|
||||
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
@@ -20,7 +20,9 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT")
|
||||
// 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"))
|
||||
|
||||
+71
-17
@@ -14,31 +14,67 @@ 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] не
|
||||
* переименовывался.
|
||||
* Единственный класс для message-таблицы. Используется напрямую через
|
||||
* `:journal-ksqlite` зависимость; bundle'ом собирает
|
||||
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Три формы конструктора с разной семантикой владения:
|
||||
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
|
||||
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
|
||||
* где один connection используется многими store'ами и закрывается bundle'ом.
|
||||
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
|
||||
* его в [close].
|
||||
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
|
||||
* закрывает его в [close].
|
||||
*
|
||||
* ## Миграция
|
||||
*
|
||||
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
|
||||
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
|
||||
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
|
||||
* прогоняют миграцию одной и той же схемы по очереди.
|
||||
*
|
||||
* Prepared statements (insert / list / clear) препарируются один раз в
|
||||
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
|
||||
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
|
||||
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
|
||||
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
|
||||
* 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(
|
||||
class KsqliteJournalStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val ownsConnection: Boolean,
|
||||
) : MutableJournalStore {
|
||||
|
||||
/**
|
||||
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
|
||||
* себя его закрытие в [close]. Для standalone использования, когда у
|
||||
* store'а нет bundle'а-владельца connection'а.
|
||||
*/
|
||||
constructor(path: String) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Внешнее соединение — store НЕ закрывает его в [close]. Для
|
||||
* shared-connection bundles (`SqliteStores.assemble`), где один
|
||||
* connection используется многими store'ами и закрывается bundle'ом.
|
||||
*/
|
||||
constructor(connection: SQLiteConnection) : this(
|
||||
connection = connection,
|
||||
ownsConnection = false,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -94,13 +130,15 @@ class KsqliteJournalStore internal constructor(
|
||||
listStmt.bindLong(4, offset.toLong())
|
||||
val out = mutableListOf<MessageRecord>()
|
||||
listStmt.executeQuery().use { rs ->
|
||||
while (rs.next()) out.add(rs.toMessageRecord(json))
|
||||
while (rs.next()) {
|
||||
out.add(rs.toMessageRecord(json))
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||
mutex.withLock {
|
||||
clearStmt.reset()
|
||||
clearStmt.clearBindings()
|
||||
@@ -113,5 +151,21 @@ class KsqliteJournalStore internal constructor(
|
||||
insertStmt.close()
|
||||
listStmt.close()
|
||||
clearStmt.close()
|
||||
if (ownsConnection) {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Открывает in-memory соединение через [SQLiteConnection.memory] и
|
||||
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
|
||||
* runtime.
|
||||
*/
|
||||
fun memory(name: String? = null) =
|
||||
KsqliteJournalStore(
|
||||
connection = SQLiteConnection.memory(name),
|
||||
ownsConnection = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+53
-9
@@ -1,9 +1,9 @@
|
||||
package pw.binom.agentik.storage.ksqlite
|
||||
package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.MutableConversationStore
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -11,15 +11,52 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* ksqlite-реализация [ConversationStore]. Схема таблицы `conversation` живёт
|
||||
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
|
||||
* в [Schema] (миграция через PRAGMA user_version) — этот класс только
|
||||
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
|
||||
*
|
||||
* Каскадное удаление связанных данных (message + working_memory) делает
|
||||
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
|
||||
* store знает только про свою таблицу.
|
||||
*
|
||||
* ## Lifecycle соединения
|
||||
*
|
||||
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
|
||||
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
|
||||
* store НЕ закрывает его в [close] (используется shared-connection
|
||||
* bundle'ом `SqliteStores.assemble`).
|
||||
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
|
||||
* закрывает его в [close].
|
||||
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
|
||||
* в [close].
|
||||
*/
|
||||
class KsqliteConversationStore(
|
||||
class KsqliteMutableConversationStore private constructor(
|
||||
private val connection: SQLiteConnection,
|
||||
private val messageStore: KsqliteMessageStore? = null,
|
||||
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
|
||||
) : ConversationStore {
|
||||
private val ownsConnection: Boolean,
|
||||
) : MutableConversationStore {
|
||||
|
||||
/**
|
||||
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
|
||||
* себя его закрытие в [close]. Для standalone использования, когда у
|
||||
* store'а нет bundle'а-владельца connection'а.
|
||||
*/
|
||||
constructor(path: String) : this(
|
||||
connection = SQLiteConnection.open(path = path),
|
||||
ownsConnection = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Внешнее соединение — store НЕ закрывает его в [close]. Для
|
||||
* shared-connection bundles (`SqliteStores.assemble`).
|
||||
*/
|
||||
constructor(connection: SQLiteConnection) : this(
|
||||
connection = connection,
|
||||
ownsConnection = false,
|
||||
)
|
||||
|
||||
init {
|
||||
Schema.migrate(connection)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -125,8 +162,6 @@ class KsqliteConversationStore(
|
||||
// Проверяем существование через raw query, НЕ через get() — get() тоже
|
||||
// берёт mutex (не реентрант), что привело бы к deadlock.
|
||||
if (!execExists(id)) return@withContext false
|
||||
messageStore?.clear(id)
|
||||
workingMemoryStore?.clear(id)
|
||||
deleteStmt.reset()
|
||||
deleteStmt.clearBindings()
|
||||
deleteStmt.bindText(1, id)
|
||||
@@ -188,6 +223,15 @@ class KsqliteConversationStore(
|
||||
renameStmt.close()
|
||||
renameUpdatedAtStmt.close()
|
||||
touchStmt.close()
|
||||
if (ownsConnection) connection.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun memory(name: String? = null): KsqliteMutableConversationStore =
|
||||
KsqliteMutableConversationStore(
|
||||
connection = SQLiteConnection.memory(name),
|
||||
ownsConnection = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun execExists(id: String): Boolean {
|
||||
+15
-7
@@ -10,10 +10,8 @@ import kotlin.time.Instant
|
||||
/**
|
||||
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
|
||||
*
|
||||
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers
|
||||
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
|
||||
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
|
||||
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
|
||||
* `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
|
||||
* не в каком-то внешнем общем модуле.
|
||||
*/
|
||||
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
||||
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
|
||||
@@ -30,7 +28,7 @@ internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (r
|
||||
)
|
||||
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
|
||||
ResultPayload.serializer(),
|
||||
ResultPayload(toolCallId = record.toolCallId, result = record.result),
|
||||
ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
|
||||
)
|
||||
is MessageRecord.Error -> "error" to Json.encodeToString(
|
||||
ErrorPayload.serializer(),
|
||||
@@ -59,7 +57,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
}
|
||||
"tool_result" -> {
|
||||
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
||||
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, result = p.result, createdAt = createdAt)
|
||||
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)
|
||||
@@ -72,8 +70,18 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
@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 result: String?)
|
||||
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?)
|
||||
|
||||
@@ -5,32 +5,51 @@ import pw.binom.db.ksqlite.SQLiteConnection
|
||||
/**
|
||||
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
|
||||
*
|
||||
* Минимум — только то, что относится к `message` (append-only audit log).
|
||||
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
|
||||
* живут в других ksqlite-модулях.
|
||||
* Владеет двумя таблицами:
|
||||
* - `conversation` — реестр диалогов агента (см. ConversationRecord);
|
||||
* - `message` — append-only audit log сообщений диалогов.
|
||||
*
|
||||
* `working_memory` и `reflection` живут в других ksqlite-модулях.
|
||||
*
|
||||
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||
*/
|
||||
internal object Schema {
|
||||
object Schema {
|
||||
|
||||
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||
const val CURRENT_VERSION: Int = 1
|
||||
|
||||
// ───── Таблица ─────
|
||||
// ───── Таблицы ─────
|
||||
const val TABLE_CONVERSATION = "conversation"
|
||||
const val TABLE_MESSAGE = "message"
|
||||
|
||||
// ───── Колонки ─────
|
||||
// ───── Колонки conversation ─────
|
||||
const val COL_ID = "id"
|
||||
const val COL_TITLE = "title"
|
||||
const val COL_IS_TEMPORAL = "is_temporal"
|
||||
const val COL_CREATED_AT = "created_at"
|
||||
const val COL_UPDATED_AT = "updated_at"
|
||||
|
||||
// ───── Колонки message ─────
|
||||
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_CONV_UPDATED = "idx_conv_updated"
|
||||
const val IDX_MSG_CONV = "idx_msg_conv"
|
||||
|
||||
private val v1Ddl = """
|
||||
private val v1ConversationDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_TITLE TEXT,
|
||||
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_CREATED_AT INTEGER NOT NULL,
|
||||
$COL_UPDATED_AT INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
private val v1MessageDdl = """
|
||||
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
|
||||
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||
@@ -38,54 +57,43 @@ internal object Schema {
|
||||
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||
$COL_CREATED_AT INTEGER NOT NULL
|
||||
);
|
||||
""".trimIndent()
|
||||
"""
|
||||
|
||||
private val v1IndexesDdl = """
|
||||
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
|
||||
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
|
||||
|
||||
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
|
||||
-- сортировка по created_at (используется list(), cascade-clear, etc.)
|
||||
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` оставит БД на предыдущей версии.
|
||||
* Гарантии:
|
||||
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||
* уже-мигрированной БД;
|
||||
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||
* ROLLBACK оставит БД консистентной.
|
||||
*
|
||||
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
|
||||
* user_version как gate перестал работать — два модуля ставят его в 1,
|
||||
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||
* idempotently; координация multi-module миграций — ответственность
|
||||
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
|
||||
*/
|
||||
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(v1ConversationDdl)
|
||||
conn.exec(v1MessageDdl)
|
||||
conn.exec(v1IndexesDdl)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+3
-10
@@ -13,15 +13,9 @@ 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.
|
||||
* Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
|
||||
* SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
|
||||
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||
*/
|
||||
class KsqliteJournalStoreTest {
|
||||
|
||||
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
|
||||
Schema.migrate(conn)
|
||||
store = KsqliteJournalStore(conn)
|
||||
}
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
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.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Тесты для [KsqliteMutableConversationStore]. Автономная фикстура —
|
||||
* `SQLiteConnection.memory(...)` + конструктор `KsqliteMutableConversationStore(connection)`.
|
||||
* Store сам прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||
*/
|
||||
class KsqliteMutableConversationStoreTest {
|
||||
|
||||
private lateinit var conn: SQLiteConnection
|
||||
private lateinit var store: KsqliteMutableConversationStore
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
conn = SQLiteConnection.memory("conv-${kotlin.random.Random.nextLong()}")
|
||||
store = KsqliteMutableConversationStore(conn)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
store.close()
|
||||
conn.close()
|
||||
}
|
||||
|
||||
private fun rec(id: String, title: String? = null, ts: Instant = Instant.parse("2026-09-15T10:00:00Z")) =
|
||||
ConversationRecord(id, title, false, ts, ts)
|
||||
|
||||
@Test
|
||||
fun testUpsertAndGetRoundtrip() = runTest {
|
||||
store.upsert(rec("c1", "test"))
|
||||
assertEquals(rec("c1", "test"), store.get("c1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetReturnsNullForMissing() = runTest {
|
||||
assertNull(store.get("nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteRemovesAndReturnsTrue() = runTest {
|
||||
store.upsert(rec("c1"))
|
||||
assertTrue(store.delete("c1"))
|
||||
assertNull(store.get("c1"))
|
||||
assertEquals(false, store.delete("c1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListSortsByUpdatedAtDesc() = runTest {
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.upsert(rec("c1", ts = t0))
|
||||
store.upsert(rec("c2", ts = t0))
|
||||
store.upsert(rec("c3", ts = t0))
|
||||
store.upsert(rec("c4", ts = t0))
|
||||
|
||||
store.touch("c2", t0 + Duration.parse("PT60S"))
|
||||
store.touch("c3", t0 + Duration.parse("PT120S"))
|
||||
store.touch("c4", t0 + Duration.parse("PT180S"))
|
||||
|
||||
val page = store.list(offset = 0, limit = 4)
|
||||
assertEquals(listOf("c4", "c3", "c2", "c1"), page.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testListRespectsOffsetAndLimit() = runTest {
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
for (i in 1..5) store.upsert(rec("c$i", ts = t0 + Duration.parse("PT${i}S")))
|
||||
val p0 = store.list(offset = 0, limit = 2)
|
||||
assertEquals(2, p0.size)
|
||||
val p2 = store.list(offset = 4, limit = 2)
|
||||
assertEquals(1, p2.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRenameUpdatesTitleAndUpdatedAt() = runTest {
|
||||
store.upsert(rec("c1"))
|
||||
val newTs = store.rename("c1", "new title")
|
||||
assertNotNull(newTs)
|
||||
assertEquals("new title", store.get("c1")?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRenameWithNullClearsTitle() = runTest {
|
||||
store.upsert(rec("c1", "old"))
|
||||
store.rename("c1", null)
|
||||
assertNull(store.get("c1")?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRenameReturnsNullForMissing() = runTest {
|
||||
assertNull(store.rename("nope", "x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTouchUpdatesUpdatedAtOnly() = runTest {
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
|
||||
store.touch("c1", t1)
|
||||
val got = store.get("c1")
|
||||
assertEquals("title", got?.title)
|
||||
assertEquals(t1, got?.updatedAt)
|
||||
assertEquals(t0, got?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMemoryFactoryAutoMigratesSchema() = runTest {
|
||||
// Smoke-test: .memory() companion-фабрика должна прогнать Schema.migrate()
|
||||
// автоматически. Если бы миграция не сработала — storePreparedStatement'ы
|
||||
// упали бы на `prepare failed: no such table: conversation` ещё в конструкторе.
|
||||
val owned = KsqliteMutableConversationStore.memory("conv-auto-${kotlin.random.Random.nextLong()}")
|
||||
try {
|
||||
owned.upsert(rec("c1", "hello"))
|
||||
assertEquals("hello", owned.get("c1")?.title)
|
||||
} finally {
|
||||
owned.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExternalConnectionConstructorAlsoMigrates() = runTest {
|
||||
// Внешний конструктор `(connection)` ТОЖЕ мигрирует (Schema.migrate idempotent).
|
||||
// Caller может не звать Schema.migrate перед конструктором.
|
||||
val externalConn = SQLiteConnection.memory("conv-external-${kotlin.random.Random.nextLong()}")
|
||||
val s = KsqliteMutableConversationStore(externalConn)
|
||||
try {
|
||||
s.upsert(rec("c1"))
|
||||
assertNotNull(s.get("c1"))
|
||||
} finally {
|
||||
s.close()
|
||||
externalConn.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-62
@@ -1,26 +1,31 @@
|
||||
package pw.binom.agentik.storage.ksqlite
|
||||
package pw.binom.agentik.journal.ksqlite
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.journal.Content
|
||||
import pw.binom.agentik.journal.ConversationRecord
|
||||
import pw.binom.agentik.journal.MessageRecord
|
||||
import pw.binom.db.ksqlite.SQLiteConnection
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Тесты на Schema.migrate():
|
||||
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION;
|
||||
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на
|
||||
* повторных CREATE);
|
||||
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores),
|
||||
* migrate() приводит её в боевое состояние.
|
||||
* Тесты на Schema.migrate() в `:journal-ksqlite`:
|
||||
* - fresh DB → создаются `conversation` + `message` + индексы
|
||||
* (`idx_conv_updated`, `idx_msg_conv`);
|
||||
* - уже мигрированная БД → migrate() идемпотентен (no-op);
|
||||
* - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
|
||||
* migrate() приводит её в боевое состояние;
|
||||
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
|
||||
* делают full-scan по message.
|
||||
*
|
||||
* Также проверяем что наличие индекса idx_msg_conv (conversation_id +
|
||||
* created_at) — обязательный hot-path для list()/cascade-delete.
|
||||
* `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
|
||||
* в `pw.binom.agentik.reflection.ksqlite`.
|
||||
*/
|
||||
class SchemaMigrationTest {
|
||||
|
||||
@Test
|
||||
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest {
|
||||
fun `fresh DB gets conversation and message tables and indexes`() = runTest {
|
||||
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
|
||||
try {
|
||||
Schema.migrate(conn)
|
||||
@@ -28,8 +33,6 @@ class SchemaMigrationTest {
|
||||
for (table in listOf(
|
||||
Schema.TABLE_CONVERSATION,
|
||||
Schema.TABLE_MESSAGE,
|
||||
Schema.TABLE_WORKING_MEMORY,
|
||||
Schema.TABLE_REFLECTION,
|
||||
)) {
|
||||
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
|
||||
}
|
||||
@@ -37,15 +40,9 @@ class SchemaMigrationTest {
|
||||
for (index in listOf(
|
||||
Schema.IDX_CONV_UPDATED,
|
||||
Schema.IDX_MSG_CONV,
|
||||
Schema.IDX_WM_UNIQUE,
|
||||
Schema.IDX_WM_CONV,
|
||||
Schema.IDX_REFLECTION_CREATED,
|
||||
Schema.IDX_REFLECTION_CONV,
|
||||
)) {
|
||||
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
|
||||
}
|
||||
|
||||
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
@@ -56,66 +53,50 @@ class SchemaMigrationTest {
|
||||
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
|
||||
try {
|
||||
Schema.migrate(conn)
|
||||
val versionAfterFirst = readUserVersion(conn)
|
||||
|
||||
// повторный вызов не должен ни упасть, ни изменить версию, ни
|
||||
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
|
||||
// повторный вызов не должен ни упасть, ни пересоздать таблицы
|
||||
// (CREATE IF NOT EXISTS — no-op)
|
||||
Schema.migrate(conn)
|
||||
assertEquals(versionAfterFirst, readUserVersion(conn))
|
||||
Schema.migrate(conn)
|
||||
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
|
||||
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest {
|
||||
// Имитируем сценарий: существующая БД без schema, открываем через
|
||||
// ksqlite и прогоняем migrate руками (тот же путь, что в
|
||||
// KsqliteStores.open, но без зависимости от фабрики).
|
||||
fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
|
||||
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
|
||||
Schema.migrate(conn)
|
||||
|
||||
// Сборка bundle через internal-конструктор — KsqliteStores primary
|
||||
// constructor internal, тест в том же модуле и может его звать.
|
||||
val stores = KsqliteStores(
|
||||
connection = conn,
|
||||
conversations = KsqliteConversationStore(conn),
|
||||
messages = KsqliteMessageStore(conn),
|
||||
workingMemory = KsqliteWorkingMemoryStore(conn),
|
||||
reflections = KsqliteReflectionStore(conn),
|
||||
)
|
||||
val convStore = KsqliteMutableConversationStore(conn)
|
||||
val msgStore = KsqliteJournalStore(conn)
|
||||
try {
|
||||
// bundle работает end-to-end — conversation upsert + message append +
|
||||
// list. Никаких "no such table" или подобного.
|
||||
stores.conversations.upsert(
|
||||
pw.binom.agentik.journal.ConversationRecord(
|
||||
convStore.upsert(
|
||||
ConversationRecord(
|
||||
id = "c1", title = "t", isTemporal = false,
|
||||
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
|
||||
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
|
||||
)
|
||||
)
|
||||
stores.messages.append(
|
||||
pw.binom.agentik.journal.MessageRecord.UserMessage(
|
||||
msgStore.append(
|
||||
MessageRecord.UserMessage(
|
||||
id = "m1", conversationId = "c1",
|
||||
content = listOf(pw.binom.agentik.journal.Content.Text("hi")),
|
||||
content = listOf(Content.Text("hi")),
|
||||
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
|
||||
)
|
||||
)
|
||||
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
|
||||
val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
|
||||
assertEquals(1, got.size)
|
||||
assertEquals("m1", got[0].id)
|
||||
} finally {
|
||||
// Закрываем store'ы → они закроют свои pre-prepared statements
|
||||
// (StmtHolder.finalize увидит isOpen == false и не полезет в
|
||||
// нативный sqlite3_finalize с уже-разрушенным db mutex).
|
||||
stores.close()
|
||||
convStore.close()
|
||||
msgStore.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
|
||||
// Проверяем что индекс действительно покрывает обе колонки — без
|
||||
// этого list()/cascade-delete будут делать full-scan по message.
|
||||
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
|
||||
try {
|
||||
Schema.migrate(conn)
|
||||
@@ -158,16 +139,4 @@ class SchemaMigrationTest {
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||
var version = 0
|
||||
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||
stmt.executeQuery().use { rs ->
|
||||
if (rs.next()) {
|
||||
version = (rs.getLong(0) ?: 0L).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
return version
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@
|
||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||
//
|
||||
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
|
||||
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
|
||||
// доступные JVM target'у.
|
||||
// KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
|
||||
// `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
|
||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||
|
||||
plugins {
|
||||
@@ -15,6 +14,14 @@ kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
macosX64()
|
||||
macosArm64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
@@ -24,16 +31,18 @@ kotlin {
|
||||
api(project(":context-api"))
|
||||
api(project(":skills"))
|
||||
api(libs.litert.api)
|
||||
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
|
||||
// jvm/native variant из общего артефакта).
|
||||
implementation(libs.kotlin.logging)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// mu.KotlinLogging — JVM-only, для SkillMiner'а
|
||||
implementation(libs.kotlin.logging)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.litert.tools.kotlinx.serialization)
|
||||
implementation(project(":memory-md"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class LlmReflector(
|
||||
private val llm: LiteLlm,
|
||||
val maxTurns: Int = 6,
|
||||
private val maxTokens: Int = 512,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.litert.LiteConversation
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteDelta
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.litert.LiteMessage
|
||||
import pw.binom.litert.LiteRole
|
||||
import pw.binom.litert.LiteToolCall
|
||||
|
||||
/**
|
||||
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
|
||||
* заданной строкой [reply] двумя фрагментами + done.
|
||||
*/
|
||||
internal class FakeLiteLlm : LiteLlm {
|
||||
sealed class Reply {
|
||||
data class Text(val text: String) : Reply()
|
||||
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
|
||||
}
|
||||
|
||||
override val backendName: String = "fake"
|
||||
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||
var reply: String = ""
|
||||
var rememberHistory: Boolean = false
|
||||
var slow: Boolean = false
|
||||
var failMessage: String? = null
|
||||
|
||||
/**
|
||||
* Если задан, LLM проходит по этому списку ответов по порядку: первый
|
||||
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
|
||||
* кончился — fallback на [reply] (text).
|
||||
*/
|
||||
var scriptedReplies: MutableList<Reply> = mutableListOf()
|
||||
|
||||
var lastConfig: LiteConversationConfig? = null
|
||||
var lastContents: List<LiteContentPart>? = null
|
||||
val conversations = mutableListOf<FakeLiteConversation>()
|
||||
|
||||
override fun isInitialized(): Boolean = true
|
||||
|
||||
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
||||
lastConfig = config
|
||||
val conv = FakeLiteConversation(this, config)
|
||||
conversations.add(conv)
|
||||
return conv
|
||||
}
|
||||
|
||||
override fun infer(request: pw.binom.litert.LiteRequest): String =
|
||||
throw UnsupportedOperationException("not used in test")
|
||||
|
||||
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
|
||||
throw UnsupportedOperationException("not used in test")
|
||||
|
||||
override fun close() {}
|
||||
|
||||
fun nextReply(): Reply =
|
||||
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
|
||||
}
|
||||
|
||||
internal class FakeLiteConversation(
|
||||
private val parent: FakeLiteLlm,
|
||||
config: LiteConversationConfig,
|
||||
) : LiteConversation {
|
||||
val initialMessages: List<LiteMessage> = config.initialMessages
|
||||
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
||||
override val history: List<LiteMessage> get() = mutableHistory.toList()
|
||||
override var systemInstruction: String? = config.systemInstruction
|
||||
override var tools: List<pw.binom.litert.LiteTool> = config.tools
|
||||
|
||||
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
||||
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||
|
||||
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||
parent.lastContents = contents
|
||||
parent.failMessage?.let { msg ->
|
||||
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
|
||||
}
|
||||
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
||||
val next = parent.nextReply()
|
||||
return when (next) {
|
||||
is FakeLiteLlm.Reply.Text -> {
|
||||
if (parent.slow) {
|
||||
kotlinx.coroutines.flow.flow {
|
||||
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
|
||||
kotlinx.coroutines.delay(10_000)
|
||||
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
|
||||
mutableHistory.add(LiteMessage.model(next.text))
|
||||
}
|
||||
} else {
|
||||
val first = next.text.substring(0, next.text.length / 2)
|
||||
val second = next.text.substring(next.text.length / 2)
|
||||
flowOf(
|
||||
LiteDelta(text = first),
|
||||
LiteDelta(text = second, isDone = true),
|
||||
).also { mutableHistory.add(LiteMessage.model(next.text)) }
|
||||
}
|
||||
}
|
||||
is FakeLiteLlm.Reply.ToolCalls -> {
|
||||
val calls = next.calls.map { (name, args) ->
|
||||
LiteToolCall(name = name, arguments = args)
|
||||
}
|
||||
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun replaceHistory(newHistory: List<LiteMessage>) {
|
||||
mutableHistory.clear()
|
||||
mutableHistory.addAll(newHistory)
|
||||
}
|
||||
|
||||
override fun send(prompt: String): String {
|
||||
parent.lastContents = listOf(LiteContentPart.Text(prompt))
|
||||
return parent.reply
|
||||
}
|
||||
override fun sendContents(contents: List<LiteContentPart>): String {
|
||||
parent.lastContents = contents
|
||||
return parent.reply
|
||||
}
|
||||
override fun cancel() {}
|
||||
override fun tokenCount(): Int = history.size
|
||||
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
|
||||
LiteDelta(text = "", isDone = true)
|
||||
override fun close() {}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package pw.binom.agentik.llm.tools
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import pw.binom.agentik.llm.tools.ReflectionParser
|
||||
|
||||
class ReflectionParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses clean JSON`() {
|
||||
val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(4, p.score)
|
||||
assertEquals("ok", p.summary)
|
||||
assertEquals(listOf("a", "b"), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses JSON wrapped in json fences`() {
|
||||
val raw = "```json\n" +
|
||||
"{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" +
|
||||
"```"
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(3, p.score)
|
||||
assertEquals(listOf<String>(), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses JSON with leading and trailing text`() {
|
||||
val raw = "Вот мой ответ:\n" +
|
||||
"{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" +
|
||||
"Конец."
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(2, p.score)
|
||||
assertEquals(listOf("путаю", "медленно"), p.weakSpots)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts score as string`() {
|
||||
val raw = """{"score": "5", "summary": "ok", "weakSpots": []}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
assertEquals(5, p.score)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null on missing score`() {
|
||||
val raw = """{"summary": "x", "weakSpots": []}"""
|
||||
assertNull(ReflectionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null on invalid JSON`() {
|
||||
assertNull(ReflectionParser.parse("not even json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles escape sequences in weakSpots`() {
|
||||
// raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате
|
||||
val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}"""
|
||||
val p = ReflectionParser.parse(raw)
|
||||
assertNotNull(p)
|
||||
// парсер снимает один escape: \\\\ → \\
|
||||
assertEquals(listOf("path\\\\file"), p.weakSpots)
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package pw.binom.agentik.llm.tools.memory
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
import pw.binom.agentik.memory.MemorySource
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
import pw.binom.agentik.llm.tools.FakeLiteLlm
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
|
||||
import pw.binom.agentik.llm.tools.ReviewPrompts
|
||||
|
||||
class LlmMemoryReviewerTest {
|
||||
|
||||
/**
|
||||
* Минимальный in-memory store для тестов — реализует [MemoryStore],
|
||||
* хранит заметки в MutableList, поддерживает events flow.
|
||||
*/
|
||||
private class InMemoryStore : MemoryStore {
|
||||
private val notes = mutableMapOf<String, MemoryNote>()
|
||||
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
|
||||
|
||||
override suspend fun upsert(note: MemoryNote) {
|
||||
notes[note.id] = note
|
||||
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||
}
|
||||
|
||||
override suspend fun get(id: String): MemoryNote? = notes[id]
|
||||
override suspend fun list(
|
||||
category: MemoryCategory?,
|
||||
conversationId: String?,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): List<MemoryNote> = notes.values
|
||||
.filter { category == null || it.category == category }
|
||||
.filter { conversationId == null || it.conversationId == conversationId }
|
||||
.sortedByDescending { it.lastUsedAt }
|
||||
.drop(offset)
|
||||
.take(limit)
|
||||
|
||||
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> = emptyList()
|
||||
|
||||
override suspend fun delete(id: String): Boolean = notes.remove(id) != null
|
||||
|
||||
override suspend fun markUsed(id: String, at: Instant) {
|
||||
notes[id]?.let {
|
||||
notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun events(): kotlinx.coroutines.flow.Flow<MemoryStoreEvent> = _events
|
||||
|
||||
override fun close() {}
|
||||
|
||||
// Helper for tests to seed notes
|
||||
fun seed(note: MemoryNote) {
|
||||
notes[note.id] = note
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review parses save JSON and applies upsert`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}"""
|
||||
}
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(
|
||||
ReviewedTurn(
|
||||
userMessage = "Меня Саша зовут",
|
||||
assistantMessage = "Приятно познакомиться, Саша!",
|
||||
)
|
||||
)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||
|
||||
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||
assertEquals(1, applied.saved)
|
||||
|
||||
val all = store.list()
|
||||
assertEquals(1, all.size)
|
||||
assertEquals("Имя — Саша", all[0].content)
|
||||
assertEquals(MemorySource.AUTO_REVIEW, all[0].source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review applies delete decisions`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """{"save":[],"delete":["mem-stale-1"]}"""
|
||||
}
|
||||
val store = InMemoryStore().apply {
|
||||
seed(
|
||||
MemoryNote(
|
||||
id = "mem-stale-1",
|
||||
category = MemoryCategory.USER,
|
||||
content = "stale",
|
||||
createdAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||
useCount = 0,
|
||||
source = MemorySource.AUTO_REVIEW,
|
||||
)
|
||||
)
|
||||
}
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(ReviewedTurn("удали это", "ок"))
|
||||
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||
assertEquals(0, applied.saved)
|
||||
assertEquals(1, applied.deleted)
|
||||
assertEquals(0, store.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review returns empty decision when LLM produces garbage`() = runTest {
|
||||
val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." }
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||
assertTrue(decision.toSave.isEmpty())
|
||||
assertTrue(decision.toDelete.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review handles empty LLM reply`() = runTest {
|
||||
val llm = FakeLiteLlm().apply { reply = "" }
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||
assertTrue(decision.toSave.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `review creates conversation with review system prompt`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """{"save":[],"delete":[]}"""
|
||||
}
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
reviewer.review(ReviewedTurn("u", "a"))
|
||||
|
||||
assertNotNull(llm.lastConfig)
|
||||
assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reviewPreCompaction processes batch of turns`() = runTest {
|
||||
val llm = FakeLiteLlm().apply {
|
||||
reply = """
|
||||
{"save":[
|
||||
{"category":"USER","content":"Работает в Яндексе"},
|
||||
{"category":"WORLD","content":"JVector — pure-Java ANN"}
|
||||
],"delete":[]}
|
||||
""".trimIndent()
|
||||
}
|
||||
val store = InMemoryStore()
|
||||
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||
|
||||
val turns = listOf(
|
||||
pw.binom.agentik.memory.ConversationTurn(
|
||||
userMessage = "Я в Яндексе работаю",
|
||||
assistantMessage = "Круто!",
|
||||
),
|
||||
pw.binom.agentik.memory.ConversationTurn(
|
||||
userMessage = "А что за JVector?",
|
||||
assistantMessage = "ANN-библиотека на Java.",
|
||||
),
|
||||
)
|
||||
|
||||
val decision = reviewer.reviewPreCompaction(turns)
|
||||
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||
|
||||
assertEquals(2, applied.saved)
|
||||
assertEquals(2, store.list().size)
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package pw.binom.agentik.llm.tools.memory
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.agentik.llm.tools.ReviewDecisionParser
|
||||
|
||||
class ReviewDecisionParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses save array with USER category`() {
|
||||
val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||
assertEquals("Имя пользователя — Саша", decision.toSave[0].content)
|
||||
assertTrue(decision.toDelete.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses all three categories`() {
|
||||
val raw = """
|
||||
{"save":[
|
||||
{"category":"USER","content":"Работает в Яндексе"},
|
||||
{"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"},
|
||||
{"category":"PREFERENCE","content":"Отвечать кратко"}
|
||||
],"delete":[]}
|
||||
""".trimIndent()
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(3, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||
assertEquals(MemoryCategory.WORLD, decision.toSave[1].category)
|
||||
assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses delete array with ids`() {
|
||||
val raw = """{"save":[],"delete":["mem-123","mem-456"]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertTrue(decision.toSave.isEmpty())
|
||||
assertEquals(listOf("mem-123", "mem-456"), decision.toDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty decision on empty input`() {
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(""))
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty decision on non-JSON garbage`() {
|
||||
val raw = "Извини, я не могу помочь с этим."
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty decision on malformed JSON`() {
|
||||
val raw = """{"save":[{"category":"USER","content":"foo""" // truncated
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracts JSON from markdown code block`() {
|
||||
val raw = """
|
||||
Вот JSON:
|
||||
```json
|
||||
{"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]}
|
||||
```
|
||||
""".trimIndent()
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals(MemoryCategory.WORLD, decision.toSave[0].category)
|
||||
assertEquals("SQLite 3.51", decision.toSave[0].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips entries with unknown category`() {
|
||||
val raw = """{"save":[
|
||||
{"category":"USER","content":"valid"},
|
||||
{"category":"NOT_A_CATEGORY","content":"should be skipped"},
|
||||
{"category":"WORLD","content":"valid too"}
|
||||
],"delete":[]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(2, decision.toSave.size)
|
||||
assertEquals("valid", decision.toSave[0].content)
|
||||
assertEquals("valid too", decision.toSave[1].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips entries with blank content`() {
|
||||
val raw = """{"save":[
|
||||
{"category":"USER","content":""},
|
||||
{"category":"WORLD","content":" "}
|
||||
],"delete":[]}"""
|
||||
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles escaped quotes in content`() {
|
||||
val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}"""
|
||||
val decision = ReviewDecisionParser.parse(raw)
|
||||
assertEquals(1, decision.toSave.size)
|
||||
assertEquals("Сказал \"привет\"", decision.toSave[0].content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# :mcp-bridge
|
||||
|
||||
Мост между протоколом [MCP](https://modelcontextprotocol.io/) (Model Context Protocol)
|
||||
и `MutableAgent` из `:agent-api`. Превращает удалённые MCP-серверы в набор
|
||||
тулов, доступных агенту через стандартный install-механизм.
|
||||
|
||||
## Что это и зачем
|
||||
|
||||
`McpRegistry` умеет подключаться к N MCP-серверам, опрашивать их
|
||||
`tools/list` и держать в памяти именованные тулы (`NamedTool`). Но
|
||||
`McpRegistry` сам по себе ничего не знает про агента — это просто реестр
|
||||
плюс JSON-RPC-клиент.
|
||||
|
||||
`McpBridgeComponent` — адаптер: реализует `pw.binom.agentik.agent.Component`
|
||||
и при `install(MutableAgent)` добавляет в `toolProviders` провайдер, который
|
||||
возвращает текущий снимок тулов из реестра. При `uninstall` — снимает.
|
||||
|
||||
## Использование
|
||||
|
||||
```kotlin
|
||||
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
|
||||
val agent = ChatAgent(...)
|
||||
.install(McpBridgeComponent(mcpRegistry))
|
||||
```
|
||||
|
||||
После `install` MCP-тулы доступны агенту через стандартный
|
||||
`toolsetDispatch.baseDispatcher` — вызываются точно так же, как и встроенные
|
||||
(skill / memory / enable_toolset). Никаких особых путей.
|
||||
|
||||
На каждом tool-call `collectTools()` (внутри ChatAgent) перебирает все
|
||||
`toolProviders` и собирает актуальный список — добавление/удаление компонента
|
||||
видно немедленно, без рестарта агента.
|
||||
|
||||
## Структура
|
||||
|
||||
- `McpRegistry` — JSON-RPC клиент, поддерживает несколько MCP-серверов,
|
||||
параллельный опрос `tools/list` при старте, reconnect.
|
||||
- `McpBridgeComponent` — адаптер к `:agent-api`. Реализует `Component`;
|
||||
держит ссылку на `McpRegistry`; на `install` пушит `McpToolProvider`
|
||||
в `MutableAgent.toolProviders`, на `uninstall` снимает.
|
||||
- `McpToolProvider` — внутренний `ToolProvider`, возвращает
|
||||
`registry.namedTools`. Пустой по сути адаптер, нужен только чтобы дать
|
||||
имя для удаления (removeAll-логика).
|
||||
|
||||
## Зависимости
|
||||
|
||||
- `:agent-api` — `MutableAgent`, `Component`, `ToolProvider`.
|
||||
- `:agent-toolsets` — `NamedTool`, `LiteTool`.
|
||||
- `:outbox-api` — для live-событий (connection-loss / reconnect notifications).
|
||||
|
||||
## НЕ включено
|
||||
|
||||
- HTTP/SSE транспорт к MCP-серверам (только stdio/JSON-RPC сейчас; HTTP-вариант
|
||||
доделывается).
|
||||
- Конвертация `McpResource` → `MemoryNote` (отдельная фича, не реализована).
|
||||
- Авторизация OAuth (MCP 2025-06-15 draft) — пока нет.
|
||||
@@ -7,8 +7,8 @@
|
||||
// или :agentik-tui когда те снова включатся.
|
||||
//
|
||||
// Зависимости:
|
||||
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
|
||||
// - litert.api для LiteTool контракта
|
||||
// - litert.api для LiteTool контракта (в v9 у LiteTool появилось поле name,
|
||||
// NamedTool-обёртка из :agent-toolsets больше не нужна)
|
||||
// - MCP SDK (JVM-only)
|
||||
// - Ktor client (для StreamableHttpClientTransport)
|
||||
// - kotlinx-serialization для парсинга конфига
|
||||
@@ -22,7 +22,9 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":agent-toolsets"))
|
||||
// :agent-api — отсюда Component / ToolProvider; McpBridgeComponent
|
||||
// реализует Component и подсовывает MCP-тулы через ToolProvider.
|
||||
api(project(":agent-api"))
|
||||
|
||||
api(libs.litert.api)
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package pw.binom.agentik.mcp.bridge
|
||||
|
||||
import pw.binom.agentik.agent.Component
|
||||
import pw.binom.agentik.agent.MutableAgent
|
||||
import pw.binom.agentik.agent.ToolProvider
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* [Component], встраивающий [McpRegistry] в [MutableAgent] через [ToolProvider].
|
||||
*
|
||||
* При [install] добавляет один [ToolProvider] в `agent.toolProviders` —
|
||||
* он возвращает `registry.allTools` (все MCP-тулы со всех подключённых
|
||||
* серверов, с префиксом `serverName__` чтобы избежать коллизий).
|
||||
*
|
||||
* При [uninstall] убирает свой [ToolProvider] обратно. Повторный `uninstall`
|
||||
* — no-op. **Не** закрывает [McpRegistry] — за это отвечает host
|
||||
* (обычно shutdown hook в `Main.kt`).
|
||||
*
|
||||
* Типичное использование:
|
||||
* ```
|
||||
* val registry = McpRegistry.fromConfig(config.mcp)
|
||||
* val agent = ChatAgent(...).install(McpBridgeComponent(registry))
|
||||
* // ...
|
||||
* Runtime.getRuntime().addShutdownHook(Thread { registry.close() })
|
||||
* ```
|
||||
*
|
||||
* Пока встраивается только в `:standalone` через `:mcp-bridge` —
|
||||
* `:agentik-cli` / `:agentik-tui` (когда снова включатся) получат эту же
|
||||
* механику без изменений в [ChatAgent] constructor'е.
|
||||
*/
|
||||
class McpBridgeComponent(
|
||||
registry: McpRegistry,
|
||||
) : Component {
|
||||
|
||||
private val provider = McpToolProvider(registry)
|
||||
|
||||
override fun install(agent: MutableAgent) {
|
||||
if (provider !in agent.toolProviders)
|
||||
agent.toolProviders += provider
|
||||
}
|
||||
|
||||
override fun uninstall(agent: MutableAgent) {
|
||||
agent.toolProviders -= provider
|
||||
}
|
||||
|
||||
/**
|
||||
* [ToolProvider] поверх [McpRegistry.allTools]: всегда отдаёт
|
||||
* полный список MCP-тулов вне зависимости от `conversationId`
|
||||
* (per-conversation фильтрация для MCP будет, если/когда понадобится —
|
||||
* сейчас MCP-тулы глобальны и для всех бесед одинаковы).
|
||||
*/
|
||||
private class McpToolProvider(
|
||||
private val registry: McpRegistry,
|
||||
) : ToolProvider {
|
||||
|
||||
override fun getTools(conversationId: String): List<LiteTool> = registry.allTools
|
||||
}
|
||||
}
|
||||
|
||||
val McpRegistry.component
|
||||
get() = McpBridgeComponent(this)
|
||||
@@ -32,7 +32,6 @@ import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.litert.LiteTool
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
/**
|
||||
* Реестр подключённых MCP-серверов.
|
||||
@@ -60,11 +59,6 @@ class McpRegistry(
|
||||
connected.values.flatMap { it.tools }
|
||||
}
|
||||
|
||||
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
|
||||
val namedTools: List<NamedTool> by lazy {
|
||||
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
|
||||
}
|
||||
|
||||
/** Количество успешно подключённых серверов. */
|
||||
val connectedServerCount: Int get() = connected.size
|
||||
|
||||
@@ -181,13 +175,13 @@ internal class McpLiteToolAdapter(
|
||||
private val client: Client,
|
||||
) : LiteTool {
|
||||
|
||||
internal val fullName: String = "${serverName}__${tool.name}"
|
||||
override val name: String = "${serverName}__${tool.name}"
|
||||
|
||||
override fun describe(): String =
|
||||
buildJsonObject {
|
||||
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
|
||||
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
|
||||
put("name", fullName)
|
||||
put("name", name)
|
||||
put("description", tool.description ?: "")
|
||||
put("parameters", tool.inputSchema.toJsonSchema())
|
||||
}.toString()
|
||||
|
||||
@@ -21,6 +21,13 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
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 {
|
||||
implementation(kotlin("test"))
|
||||
|
||||
@@ -24,4 +24,11 @@ data class MemoryNote(
|
||||
val useCount: Int = 0,
|
||||
val conversationId: String? = null,
|
||||
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 runner'а — text-embedding-kmp ещё не
|
||||
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
|
||||
// не подключаются. Раньше был нужен потому что text-embedding-kmp был
|
||||
// только в локальном ~/.m2; с 2026-09-21 (v4 в caffeine) можно убрать,
|
||||
// но оставлен на случай если CI внезапно отвалится от Nexus.
|
||||
// Использование:
|
||||
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
||||
// Локальная разработка без флага — зависимости подключаются как обычно.
|
||||
@@ -25,6 +26,10 @@ kotlin {
|
||||
api(project(":memory-api"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
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 {
|
||||
implementation(kotlin("test"))
|
||||
@@ -34,14 +39,11 @@ kotlin {
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.jvector)
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
// Конкретная реализация TextEmbeddingExtractor поверх ONNX.
|
||||
implementation(libs.text.embedding.siglip)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
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
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
import pw.binom.agentik.memory.MemoryVectorIndex as KmpMemoryVectorIndex
|
||||
import pw.binom.agentik.memory.ScoredVector as KmpScoredVector
|
||||
|
||||
/**
|
||||
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1].
|
||||
* Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||
*/
|
||||
data class ScoredVector(
|
||||
val id: String,
|
||||
val score: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
|
||||
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
|
||||
* JVM-only alias на KMP-контракт из `:memory-api`. Удалять нельзя — пока
|
||||
* `:memory-vector` существует как JVM-only модуль с JVector-имплементацией,
|
||||
* все его internal helper'ы продолжают импортировать `MemoryVectorIndex` из
|
||||
* `pw.binom.agentik.memory.vector.*` (старое FQN). После удаления модуля —
|
||||
* можно убрать этот файл и переименовать пакеты импортов.
|
||||
*
|
||||
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это
|
||||
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||
* Раньше жил прямо здесь (`MemoryVectorIndex` + `ScoredVector` в
|
||||
* `:memory-vector/commonMain`), но переехал в `:memory-api` 2026-09-21
|
||||
* чтобы стать доступным из KMP-модуля `:memory-md-vector`.
|
||||
*/
|
||||
interface MemoryVectorIndex : AutoCloseable {
|
||||
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||
val dimension: Int
|
||||
|
||||
/** Количество записей в индексе. */
|
||||
suspend fun size(): Long
|
||||
@Deprecated(
|
||||
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||
replaceWith = ReplaceWith(
|
||||
"MemoryVectorIndex",
|
||||
"pw.binom.agentik.memory.MemoryVectorIndex",
|
||||
),
|
||||
)
|
||||
typealias MemoryVectorIndex = KmpMemoryVectorIndex
|
||||
|
||||
/** Добавить или заменить запись по [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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Доп. контекст для 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
|
||||
}
|
||||
@Deprecated(
|
||||
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||
replaceWith = ReplaceWith(
|
||||
"ScoredVector",
|
||||
"pw.binom.agentik.memory.ScoredVector",
|
||||
),
|
||||
)
|
||||
typealias ScoredVector = KmpScoredVector
|
||||
|
||||
+8
-6
@@ -6,6 +6,8 @@ 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.noteMatches
|
||||
import kotlin.math.exp
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -23,13 +25,13 @@ import kotlinx.coroutines.sync.withLock
|
||||
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
|
||||
* потом re-rank по recency.
|
||||
*
|
||||
* [embeddingProvider] обязателен — используется для эмбеддинга контента при
|
||||
* [embedding] обязателен — используется для эмбеддинга контента при
|
||||
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
|
||||
*/
|
||||
class VectorMemoryStore(
|
||||
private val index: MemoryVectorIndex,
|
||||
private val metaStore: MemoryMetaStore,
|
||||
private val embeddingProvider: EmbeddingProvider,
|
||||
private val embedding: TextEmbeddingExecutor,
|
||||
) : MemoryStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
@@ -37,9 +39,9 @@ class VectorMemoryStore(
|
||||
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
|
||||
|
||||
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
|
||||
val embedding = embeddingProvider.embed(note.content)
|
||||
metaStore.put(note, embedding)
|
||||
index.add(note.id, embedding)
|
||||
val vec = embedding.embed(note.content)
|
||||
metaStore.put(note, vec)
|
||||
index.add(note.id, vec)
|
||||
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ class VectorMemoryStore(
|
||||
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
|
||||
|
||||
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)
|
||||
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
|
||||
// через [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.MemorySystem
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
|
||||
/**
|
||||
* Бандл компонентов vector-бэкенда памяти — то же, что
|
||||
@@ -36,19 +37,20 @@ class VectorMemorySystem(
|
||||
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
|
||||
*
|
||||
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
|
||||
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient
|
||||
* @param embedding [TextEmbeddingExecutor] — обычно HttpEmbeddingClient.asExecutor()
|
||||
* @param topK размер top-K для prefetch
|
||||
*/
|
||||
fun open(
|
||||
dbPath: String,
|
||||
embedding: EmbeddingProvider,
|
||||
embedding: TextEmbeddingExecutor,
|
||||
topK: Int = 10,
|
||||
): VectorMemorySystem {
|
||||
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension)
|
||||
val dim = embedding.dimension
|
||||
val metaStore = SqliteMemoryMetaStore.open(dbPath, dim)
|
||||
// Граф пересобирается из SQLite (источник правды): без seed'ов
|
||||
// после рестарта in-RAM индекс пуст и search возвращал бы [],
|
||||
// пока не появятся новые upsert'ы.
|
||||
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries())
|
||||
val index = JVectorMemoryIndex(dim, metaStore.allEntries())
|
||||
val store = VectorMemoryStore(index, metaStore, embedding)
|
||||
val prefetcher = VectorPrefetcher(store, topK)
|
||||
val reviewer = VectorMemoryReviewer(store)
|
||||
@@ -59,7 +61,7 @@ class VectorMemorySystem(
|
||||
closables = listOfNotNull(
|
||||
metaStore,
|
||||
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.HttpResponse
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
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.
|
||||
* Используется при memory-backend=vector.
|
||||
*
|
||||
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов
|
||||
* к API на одинаковых промптах.
|
||||
* Реализует [TextEmbeddingExtractor] (из text-embedding-kmp:api) + оборачивается
|
||||
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||
* VectorMemoryStore. LRU-кэш на [cacheSize] текстов (default 256) — дедупликация
|
||||
* запросов к API на одинаковых промптах.
|
||||
*
|
||||
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
|
||||
* @param apiKey bearer-токен
|
||||
@@ -35,9 +36,9 @@ class HttpEmbeddingClient(
|
||||
private val apiUrl: String,
|
||||
private val apiKey: String,
|
||||
private val model: String,
|
||||
override val dimension: Int,
|
||||
private val dimension: Int,
|
||||
cacheSize: Int = 256,
|
||||
) : EmbeddingProvider, AutoCloseable {
|
||||
) : TextEmbeddingExtractor {
|
||||
|
||||
private val cache = LruCache<String, FloatArray>(cacheSize)
|
||||
private val http: HttpClient = HttpClient.newBuilder()
|
||||
@@ -45,11 +46,11 @@ class HttpEmbeddingClient(
|
||||
.build()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun embed(text: String): FloatArray {
|
||||
cache.get(text)?.let { return it }
|
||||
override fun embed(text: String): TextEmbedding {
|
||||
cache.get(text)?.let { return TextEmbedding(it) }
|
||||
val vector = fetchEmbedding(text)
|
||||
cache.put(text, vector)
|
||||
return vector
|
||||
return TextEmbedding(vector)
|
||||
}
|
||||
|
||||
private fun fetchEmbedding(text: String): FloatArray {
|
||||
@@ -83,6 +84,9 @@ class HttpEmbeddingClient(
|
||||
}
|
||||
|
||||
override fun close() = http.close()
|
||||
|
||||
/** Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью. */
|
||||
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = dimension)
|
||||
}
|
||||
|
||||
private class LruCache<K, V>(private val capacity: Int) {
|
||||
|
||||
+22
-26
@@ -1,25 +1,22 @@
|
||||
package pw.binom.agentik.memory.vector.embedding
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||
|
||||
/**
|
||||
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
|
||||
*
|
||||
* Особенности:
|
||||
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU),
|
||||
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать
|
||||
* доступ из нескольких корутин (ONNX-сессия не reentrant).
|
||||
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр
|
||||
* `dimension` в конструкторе не принимаем — берём через [probeDimension].
|
||||
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на
|
||||
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется —
|
||||
* легко добавить.
|
||||
* Реализует [TextEmbeddingExtractor] напрямую (делегирует в
|
||||
* `createSiglip2TextExtractor` из text-embedding-kmp:siglip) + оборачивается
|
||||
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||
* VectorMemoryStore. Сиглизация через `Dispatchers.IO` теперь внутри
|
||||
* `TextEmbeddingExecutor.embed` — раньше лежала здесь.
|
||||
*
|
||||
* Размерность фиксирована extractor'ом (SigLIP2-base = 768); передаём
|
||||
* явно в [asExecutor].
|
||||
*
|
||||
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
|
||||
* Скачать: см. README репы `text-embedding-kmp`.
|
||||
@@ -27,23 +24,22 @@ import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||
class SiglipEmbeddingProvider(
|
||||
modelPath: String,
|
||||
tokenizerPath: String,
|
||||
) : EmbeddingProvider, AutoCloseable {
|
||||
) : TextEmbeddingExtractor {
|
||||
|
||||
private val extractor: TextEmbeddingExtractor =
|
||||
private val delegate: TextEmbeddingExtractor =
|
||||
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
||||
|
||||
override val dimension: Int = run {
|
||||
val probe = extractor.embed("probe")
|
||||
probe.dim
|
||||
}
|
||||
override fun embed(text: String): TextEmbedding = delegate.embed(text)
|
||||
|
||||
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() {
|
||||
extractor.close()
|
||||
companion object {
|
||||
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 (на случай если что-то там есть).
|
||||
val seedEntries = metaStore.allEntries()
|
||||
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim))
|
||||
store = VectorMemoryStore(index, metaStore, fakeEmbeddingExecutor(dimension = dim))
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
@@ -127,7 +127,7 @@ class VectorMemoryStoreTest {
|
||||
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
||||
val seedEntries = meta2.allEntries()
|
||||
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim))
|
||||
val store2 = VectorMemoryStore(idx2, meta2, fakeEmbeddingExecutor(dimension = dim))
|
||||
try {
|
||||
assertEquals(2L, idx2.size())
|
||||
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
|
||||
@@ -141,11 +141,11 @@ class VectorMemoryStoreTest {
|
||||
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
|
||||
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
|
||||
// из 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.close()
|
||||
|
||||
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
||||
val second = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
|
||||
try {
|
||||
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
|
||||
assertTrue(results.any { it.note.id == "r" })
|
||||
|
||||
+11
-5
@@ -17,17 +17,18 @@ import kotlin.test.assertTrue
|
||||
class SiglipEmbeddingProviderTest {
|
||||
|
||||
@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")
|
||||
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
|
||||
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
|
||||
}
|
||||
SiglipEmbeddingProvider(
|
||||
val provider = SiglipEmbeddingProvider(
|
||||
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
|
||||
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
|
||||
).use { provider ->
|
||||
).asExecutor()
|
||||
provider.use {
|
||||
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)
|
||||
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
|
||||
}
|
||||
@@ -41,7 +42,7 @@ class SiglipEmbeddingProviderTest {
|
||||
SiglipEmbeddingProvider(
|
||||
modelPath = nonExistent.absolutePath,
|
||||
tokenizerPath = nonExistent.absolutePath,
|
||||
).use { it.dimension }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +50,8 @@ class SiglipEmbeddingProviderTest {
|
||||
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,57 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Live-события уровня агента: изменения в множестве диалогов
|
||||
* (создание, удаление, переименование). События, происходящие **внутри**
|
||||
* конкретного диалога, приходят через `Conversation.events` (live-stream
|
||||
* per-turn Event'ов), а не сюда.
|
||||
*
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
|
||||
* идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла
|
||||
* используются `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
|
||||
sealed interface AgentEvent {
|
||||
/** Момент эмиссии события в UTC. */
|
||||
val date: Instant
|
||||
|
||||
/**
|
||||
* Создан новый диалог. Передаётся его id — handle можно получить через
|
||||
* `Agent.getConversation`. Подписчик после [Created] может сразу открыть
|
||||
* live-подписку на этот диалог через `Conversation.events`.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("created")
|
||||
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
|
||||
|
||||
/**
|
||||
* Диалог удалён. Переданный `Conversation`-handle реализация обязана
|
||||
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
|
||||
* пользоваться handle нельзя.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("deleted")
|
||||
data class Deleted(override val date: Instant, val id: String) : AgentEvent
|
||||
|
||||
/** У диалога сменился заголовок. */
|
||||
@Serializable
|
||||
@SerialName("renamed")
|
||||
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
|
||||
|
||||
/**
|
||||
* Обновлён `updatedAt` диалога (после `send()` или другого события,
|
||||
* бампнувшего активность). Клиентский кэш [ConversationStore] может
|
||||
* применить этот event для пересортировки списка.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("touched")
|
||||
data class Touched(override val date: Instant, val id: String, val updatedAt: Instant) : AgentEvent
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package pw.binom.agentik.outbox
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Элемент live-потока `Conversation.events(after)`.
|
||||
*
|
||||
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
||||
* порядка при равных timestamps.
|
||||
*
|
||||
* Базовая структура хода:
|
||||
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
||||
* `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
|
||||
sealed interface Event {
|
||||
/** Момент эмиссии события в UTC. */
|
||||
val date: Instant
|
||||
|
||||
@Serializable
|
||||
enum class ResponseType {
|
||||
@SerialName("text") TEXT,
|
||||
@SerialName("image") IMAGE
|
||||
}
|
||||
|
||||
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
|
||||
@Serializable
|
||||
@SerialName("start_reasoning")
|
||||
data class StartReasoning(override val date: Instant) : Event
|
||||
|
||||
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
|
||||
@Serializable
|
||||
@SerialName("start_response")
|
||||
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
||||
|
||||
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
|
||||
@Serializable
|
||||
@SerialName("end")
|
||||
data class End(override val date: Instant) : Event
|
||||
|
||||
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
|
||||
@Serializable
|
||||
@SerialName("interrupted")
|
||||
data class Interrupted(override val date: Instant) : Event
|
||||
|
||||
@Serializable
|
||||
@SerialName("append_text")
|
||||
data class AppendText(override val date: Instant, val body: String) : Event
|
||||
|
||||
@Serializable
|
||||
@SerialName("append_image")
|
||||
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
|
||||
|
||||
/**
|
||||
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||
* [id] совпадает с id соответствующего `Message.ToolCall` в истории
|
||||
* после завершения хода.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("tool_call")
|
||||
data class ToolCall(
|
||||
override val date: Instant,
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val toolName: String,
|
||||
val toolArgs: String,
|
||||
) : Event
|
||||
|
||||
/**
|
||||
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
||||
*
|
||||
* [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
|
||||
@SerialName("tool_result")
|
||||
data class ToolResult(
|
||||
override val date: Instant,
|
||||
val toolCallId: String,
|
||||
val toolName: String? = null,
|
||||
val result: String?,
|
||||
) : Event
|
||||
|
||||
/**
|
||||
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
||||
* прийти, но ход считается проваленным.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("error")
|
||||
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
||||
|
||||
/**
|
||||
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
|
||||
* kernel прибил таск). Отличается от [ToolResult]: там мы сообщаем LLM
|
||||
* результат (даже если LLM его не понравился), тут — сигнал о
|
||||
* внутренней ошибке **самого исполнения тула**. Используется
|
||||
* background-подписчиками (например [ReflectionScheduler]-like
|
||||
* компонентами) для накопления паттернов отказов. Клиенту
|
||||
* показывается для transparency, но в UI особо не нужен.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("tool_failed")
|
||||
data class ToolFailed(
|
||||
override val date: Instant,
|
||||
val toolCallId: String,
|
||||
val toolName: String?,
|
||||
val message: String,
|
||||
val durationMs: Long,
|
||||
) : Event
|
||||
|
||||
/**
|
||||
* Диалог переходит в закрытое состояние ([Conversation.close] /
|
||||
* [ConversationLoop.close] / `agent.deleteConversation`). Эмитится
|
||||
* **до** освобождения ресурсов, чтобы background-подписчики
|
||||
* (skill mining, reflection) успели сделать final pass. После
|
||||
* `Closed` диалог уже удалён из `agent.getConversations()` и
|
||||
* `getMessages()` отдаст только то, что осталось в журнале.
|
||||
*
|
||||
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
|
||||
* так как открытие тривиально (id уже известен с момента
|
||||
* `Agent.createConversation` → [Event.ConversationCreated]
|
||||
* / [AgentEvent.Created] в outbox'е).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("conversation_closing")
|
||||
data class ConversationClosing(
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
) : Event
|
||||
|
||||
/**
|
||||
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
|
||||
* Эмитится **до** deletion для background-подписчиков (skill mining),
|
||||
* чтобы они успели сделать pass на исчезающем контенте.
|
||||
*
|
||||
* В отличие от [ToolFailed]/[ConversationClosing], это событие
|
||||
* семантически "много контента ушло" — subscribers могут решать,
|
||||
* стоит ли тратить tokens на mining ([turnsCompacted] > N).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("compaction_triggered")
|
||||
data class CompactionTriggered(
|
||||
override val date: Instant,
|
||||
val conversationId: String,
|
||||
val turnsCompacted: Int,
|
||||
) : Event
|
||||
}
|
||||
@@ -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,7 +1,6 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import pw.binom.agentik.journal.ConversationStore
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import kotlin.time.Instant
|
||||
@@ -13,11 +12,16 @@ import kotlin.time.Instant
|
||||
* [createConversation] возвращает [Conversation], который сам хранит историю
|
||||
* и которому отправляют ходы через [Conversation.send].
|
||||
*
|
||||
* **Хранилища вынесены в [Agent.journal] и [Agent.outbox]**: оба read-only.
|
||||
* События больше НЕ часть [Agent] (раньше были `events()`/`allEvents()`) —
|
||||
* они теперь живут в [outbox] как `OutboxStore.events(after)` /
|
||||
* `outbox.agentEvents(after)`. Это даёт единый путь для всех read-операций
|
||||
* по хранилищу и убирает дублирование между протоколом и хранилищем.
|
||||
* **Хранилища вынесены в [Agent.journal], [Agent.outbox] и
|
||||
* [Agent.conversationStore]**: все три read-only views. События живут
|
||||
* в [outbox] как `OutboxStore.events(after)` / `outbox.agentEvents(after)`.
|
||||
* Это даёт единый путь для всех read-операций по хранилищу и убирает
|
||||
* дублирование между протоколом и хранилищем.
|
||||
*
|
||||
* **Команды** (create / delete / rename) живут прямо на [Agent]. Они
|
||||
* шлются клиентом и выполняются сервером — клиент **не** пишет в стор
|
||||
* напрямую. Клиентский кэш [conversationStore] обновляется через
|
||||
* `outbox.agentEvents()` (Created / Deleted / Renamed / Touched).
|
||||
*/
|
||||
interface Agent : AutoCloseable {
|
||||
|
||||
@@ -59,6 +63,22 @@ interface Agent : AutoCloseable {
|
||||
*/
|
||||
val outbox: OutboxStore
|
||||
|
||||
/**
|
||||
* Read-only view на `conversation` table (id + title + timestamps).
|
||||
*
|
||||
* Используется HTTP-фасадом `:server` для endpoint'а
|
||||
* `GET /{path}/conversations?offset=&limit=` — внешние клиенты
|
||||
* получают лёгкую метадату (без handle'ов и image-support флагов)
|
||||
* для рендера списка диалогов. Для активной работы (send / interrupt)
|
||||
* клиент отдельно получает handle через [getConversation].
|
||||
*
|
||||
* **Read-only**: write-доступ только через `MutableConversationStore`
|
||||
* внутри ChatAgent, не через [Agent] interface. Клиент модифицирует
|
||||
* диалоги командами: [createConversation] / [deleteConversation] /
|
||||
* [renameConversation].
|
||||
*/
|
||||
val conversationStore: ConversationStore
|
||||
|
||||
/** Создаёт новый stateful-диалог с агентом. */
|
||||
fun createConversation(temp: Boolean): Conversation
|
||||
|
||||
@@ -68,19 +88,11 @@ interface Agent : AutoCloseable {
|
||||
/** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */
|
||||
suspend fun deleteConversation(id: String): Boolean
|
||||
|
||||
/** Страница диалогов: не более [limit] штук, начиная с [offset]-го. */
|
||||
suspend fun getConversations(offset: Int, limit: Int): List<Conversation>
|
||||
|
||||
/** Все диалоги, начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */
|
||||
fun getConversations(offset: Int = 0): Flow<Conversation> = flow {
|
||||
var skip = offset
|
||||
while (true) {
|
||||
val page = getConversations(skip, PAGE_SIZE)
|
||||
if (page.isEmpty()) break
|
||||
page.forEach { emit(it) }
|
||||
skip += page.size
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Переименовывает диалог; `null` для сброса заголовка. Возвращает новый
|
||||
* `updatedAt` или `null`, если диалог не найден.
|
||||
*/
|
||||
suspend fun renameConversation(id: String, title: String?): Instant?
|
||||
|
||||
companion object {
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
/**
|
||||
* Backward-compat typealias: `AgentEvent` теперь живёт в `:outbox-api`
|
||||
* (логически принадлежит сущности outbox, не wire-протоколу `:proto`).
|
||||
*
|
||||
* Существующие импорты `pw.binom.agentik.proto.AgentEvent` продолжают
|
||||
* работать транспарентно. Использовать typealias в новом коде.
|
||||
*/
|
||||
typealias AgentEvent = pw.binom.agentik.outbox.AgentEvent
|
||||
@@ -1,9 +0,0 @@
|
||||
package pw.binom.agentik.proto
|
||||
|
||||
/**
|
||||
* Backward-compat typealias: `CommonEvent` теперь живёт в `:outbox-api`.
|
||||
*
|
||||
* Существующие импорты `pw.binom.agentik.proto.CommonEvent` продолжают
|
||||
* работать транспарентно.
|
||||
*/
|
||||
typealias CommonEvent = pw.binom.agentik.outbox.CommonEvent
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user