Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e583ac8ea | |||
| f878d1c79b | |||
| 266ec38c1b | |||
| e447525059 | |||
| 84f5fd84f3 | |||
| 639c7d1748 | |||
| 5f0e0da361 | |||
| acb4ee6186 | |||
| c0a933d251 | |||
| 29851c047a | |||
| c9995b263e | |||
| 7865eed836 | |||
| 0a7c40688c | |||
| 161be41adf | |||
| 68543357c2 | |||
| f946186ef5 | |||
| fb963bfb6b | |||
| 818f022ba3 | |||
| 0d1be42919 | |||
| 2d6cf89c52 | |||
| 8f85612665 | |||
| aef5083801 | |||
| 499db812ef | |||
| d6afc05c20 | |||
| acc7237e51 | |||
| bd65c29b48 | |||
| 2cc1d923b3 | |||
| 8f8f7f1020 | |||
| 3f260f3ac8 | |||
| d74af621d8 | |||
| 15f3952eba | |||
| a0b1209457 | |||
| a05e260457 | |||
| 7e66baf9e3 | |||
| 1134e32ea2 | |||
| e2f0e434d1 | |||
| 1f85cde1b8 | |||
| 8bb24dab3c | |||
| 7358175499 | |||
| 2d9ad526bb | |||
| dd7aec8df1 | |||
| bf2649a856 | |||
| 4ad59d5f5d | |||
| c140d0b758 |
@@ -5,6 +5,9 @@
|
|||||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||||
# release.yml для требуемых переменных.
|
# release.yml для требуемых переменных.
|
||||||
|
#
|
||||||
|
# :agentik-cli / :agentik-tui исключены из сборки (settings.gradle.kts
|
||||||
|
# 2026-09-17/21) — соответствующие шаги shadowJar тут НЕ запускаются.
|
||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -67,15 +70,10 @@ jobs:
|
|||||||
test -f standalone/build/libs/standalone-*-all.jar \
|
test -f standalone/build/libs/standalone-*-all.jar \
|
||||||
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||||
|
|
||||||
- name: Build :agentik-cli shadowJar
|
# Шага "Build :agentik-cli shadowJar" здесь нет: :agentik-cli исключён из
|
||||||
shell: bash
|
# settings.gradle.kts (2026-09-21). :agentik-tui — тоже исключён (2026-09-17).
|
||||||
run: |
|
# Когда/если оба вернутся, добавим отдельные шаги по аналогии с :standalone.
|
||||||
./gradlew :agentik-cli:shadowJar \
|
#
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
|
||||||
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
|
|
||||||
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
|
||||||
|
|
||||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||||
@@ -18,6 +18,8 @@ out/
|
|||||||
|
|
||||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
.cortexkit/
|
.cortexkit/
|
||||||
|
# opencode CLI local config (per-machine, не коммитим)
|
||||||
|
config.json
|
||||||
.veai/
|
.veai/
|
||||||
|
|
||||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ agentik/
|
|||||||
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
|
||||||
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
|
||||||
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
|
||||||
├── storage-inmemory/ in-memory реализация для тестов и Android
|
│ (исторический, см. journal-api / context-api / reflection-api ниже)
|
||||||
├── storage-sqlite/ SQLite реализация для production
|
├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
|
||||||
|
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
|
||||||
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||||
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||||
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
├── ~~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-api`](memory-api/README.md) — контракт памяти.
|
||||||
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
|
||||||
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
|
||||||
- [`:storage-core`](storage-core/README.md) — контракт storage.
|
- [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
|
||||||
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
|
- ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
|
||||||
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
|
- ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
|
||||||
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
|
- [`: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 Actions (`https://git.binom.pw/subochev/agentik/actions`):
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
|
- `.gitea/ci.yml` — PR-build, прогон тестов, проверка
|
||||||
shadowjar'ов.
|
shadowjar'ов.
|
||||||
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
|
||||||
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ budget exhaustion, registry filter, parallel dispatch.
|
|||||||
## Чего здесь НЕТ
|
## Чего здесь НЕТ
|
||||||
|
|
||||||
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
|
||||||
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
|
- Никакого persistent storage. Опирается на контракт `ContextStore`
|
||||||
(см. `:storage-core`).
|
(см. `:storage-core`).
|
||||||
|
|
||||||
## Текущий статус
|
## Текущий статус
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
|
api(project(":journal-api"))
|
||||||
api(project(":storage-core"))
|
api(project(":reflection-api"))
|
||||||
|
api(project(":context-api"))
|
||||||
|
api(project(":agent-api"))
|
||||||
|
|
||||||
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
// liteTool DSL (типизированные LiteTool через @Serializable args)
|
||||||
|
api(libs.litert.tools.kotlinx.serialization)
|
||||||
|
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
api(libs.kotlinx.serialization.core)
|
api(libs.kotlinx.serialization.core)
|
||||||
|
|||||||
+12
-12
@@ -1,5 +1,6 @@
|
|||||||
package pw.binom.agentik.toolsets
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
|
|||||||
*/
|
*/
|
||||||
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
class DisableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
val tool: LiteTool = syncLiteTool(
|
val tool: LiteTool = liteToolSuspend<DisableArgs>(
|
||||||
describeJson = DESCRIBE,
|
name = NAME,
|
||||||
handler = ::invoke,
|
description = "Deactivate a toolset by name. Its tools become unavailable.",
|
||||||
)
|
) { args ->
|
||||||
|
invoke(args)
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun invoke(args: String): String {
|
internal suspend fun invoke(args: DisableArgs): String {
|
||||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
val name = args.name
|
||||||
val toolset = registry.findByName(name)
|
val toolset = registry.findByName(name)
|
||||||
if (toolset != null) {
|
if (toolset != null) {
|
||||||
// Единообразный ответ независимо от текущего состояния.
|
|
||||||
registry.deactivate(name)
|
registry.deactivate(name)
|
||||||
return "Toolset '$name' deactivated."
|
return "Toolset '$name' deactivated."
|
||||||
}
|
}
|
||||||
// Неизвестный — перечисляем активные (что можно деактивировать)
|
|
||||||
val actives = registry.activeNames()
|
val actives = registry.activeNames()
|
||||||
return if (actives.isEmpty()) {
|
return if (actives.isEmpty()) {
|
||||||
"Toolset '$name' not found. No toolsets to deactivate."
|
"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 {
|
companion object {
|
||||||
const val NAME: String = "disable_toolset"
|
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
|
package pw.binom.agentik.toolsets
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
|
|||||||
*/
|
*/
|
||||||
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
class EnableToolsetTool(private val registry: ToolsetRegistry) {
|
||||||
|
|
||||||
val tool: LiteTool = syncLiteTool(
|
val tool: LiteTool = liteToolSuspend<EnableArgs>(
|
||||||
describeJson = DESCRIBE,
|
name = NAME,
|
||||||
handler = ::invoke,
|
description = "Activate a toolset by name to access its tools.",
|
||||||
)
|
) { args ->
|
||||||
|
invoke(args)
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun invoke(args: String): String {
|
internal suspend fun invoke(args: EnableArgs): String {
|
||||||
val name = parseName(args) ?: return "missing required argument 'name'"
|
val name = args.name
|
||||||
val toolset = registry.findByName(name)
|
val toolset = registry.findByName(name)
|
||||||
if (toolset != null) {
|
if (toolset != null) {
|
||||||
val wasActive = registry.isActive(name)
|
val wasActive = registry.isActive(name)
|
||||||
registry.activate(name)
|
registry.activate(name)
|
||||||
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
|
||||||
}
|
}
|
||||||
// Неизвестный — перечисляем доступные к активации (inactives)
|
|
||||||
val inactives = registry.inactiveNames()
|
val inactives = registry.inactiveNames()
|
||||||
return if (inactives.isEmpty()) {
|
return if (inactives.isEmpty()) {
|
||||||
"Toolset '$name' not found. No toolsets available for activation."
|
"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 {
|
companion object {
|
||||||
const val NAME: String = "enable_toolset"
|
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 kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
|
import pw.binom.litert.tools.kotlinx.serialization.liteTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Адаптер из suspend-handler'а в синхронный [LiteTool].
|
* Обёртка из suspend-handler'а в синхронный [LiteTool].
|
||||||
*
|
*
|
||||||
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
|
||||||
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
|
||||||
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
|
|||||||
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
|
||||||
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
|
||||||
*
|
*
|
||||||
|
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
|
||||||
|
* который генерит JSON-схему из `@Serializable Args` через
|
||||||
|
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
|
||||||
|
* тулов, чьи описания не получается выразить через `Args` (например, динамические
|
||||||
|
* JSON Schema, приходящие со стороны).
|
||||||
|
*
|
||||||
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
|
||||||
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
|
||||||
*/
|
*/
|
||||||
internal class SyncLiteTool(
|
internal class SyncLiteTool(
|
||||||
|
override val name: String,
|
||||||
private val describeJson: String,
|
private val describeJson: String,
|
||||||
private val handler: suspend (String) -> String,
|
private val handler: suspend (String) -> String,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
|
* Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
|
||||||
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
|
* JSON-схема генерится автоматически из `Args.descriptor`,
|
||||||
* как API-точка чтобы внешний код не зависел от internal-имени класса.
|
* а сырая строка аргументов десериализуется в типизированный [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 =
|
inline fun <reified Args> liteToolSuspend(
|
||||||
SyncLiteTool(describeJson, handler)
|
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) ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
|
|||||||
* Контекст, который тулсеты получают при активации.
|
* Контекст, который тулсеты получают при активации.
|
||||||
*
|
*
|
||||||
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
|
||||||
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
|
* добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
|
||||||
* могли читать/писать сообщения и память.
|
* могли читать/писать сообщения и память.
|
||||||
*
|
*
|
||||||
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
|
||||||
|
|||||||
+5
-12
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
|
|||||||
class DisableToolsetToolTest {
|
class DisableToolsetToolTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
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 describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok"
|
override fun invoke(arguments: String) = "ok"
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
|
|||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("media")
|
reg.activate("media")
|
||||||
val r = disable.invoke("""{"name":"media"}""")
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' deactivated.", r)
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
assertEquals(false, reg.isActive("media"))
|
assertEquals(false, reg.isActive("media"))
|
||||||
}
|
}
|
||||||
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
|
|||||||
val (disable, _) = harness(listOf(
|
val (disable, _) = harness(listOf(
|
||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
|
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
|
||||||
val r = disable.invoke("""{"name":"media"}""")
|
|
||||||
assertEquals("Toolset 'media' deactivated.", r)
|
assertEquals("Toolset 'media' deactivated.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
|
|||||||
))
|
))
|
||||||
reg.activate("a")
|
reg.activate("a")
|
||||||
reg.activate("b")
|
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)
|
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
|
|||||||
val (disable, _) = harness(listOf(
|
val (disable, _) = harness(listOf(
|
||||||
ToolsetContribution("a", "x", emptyList()),
|
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)
|
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 {
|
class EnableToolsetToolTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
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 describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok"
|
override fun invoke(arguments: String) = "ok"
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
|
|||||||
val (enable, reg) = harness(listOf(
|
val (enable, reg) = harness(listOf(
|
||||||
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
|
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("Toolset 'media' activated.", r)
|
||||||
assertEquals(true, reg.isActive("media"))
|
assertEquals(true, reg.isActive("media"))
|
||||||
}
|
}
|
||||||
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("media", "media tools", emptyList()),
|
ToolsetContribution("media", "media tools", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("media")
|
reg.activate("media")
|
||||||
val r = enable.invoke("""{"name":"media"}""")
|
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
|
||||||
assertEquals("Toolset 'media' already active.", r)
|
assertEquals("Toolset 'media' already active.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("b", "y", emptyList()),
|
ToolsetContribution("b", "y", emptyList()),
|
||||||
ToolsetContribution("c", "z", 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)
|
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
|
|||||||
ToolsetContribution("a", "x", emptyList()),
|
ToolsetContribution("a", "x", emptyList()),
|
||||||
))
|
))
|
||||||
reg.activate("a")
|
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)
|
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 {
|
class ToolsetDispatchPolicyTest {
|
||||||
|
|
||||||
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
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 describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = response
|
override fun invoke(arguments: String) = response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
|
|||||||
class ToolsetRegistryTest {
|
class ToolsetRegistryTest {
|
||||||
|
|
||||||
private fun tool(name: String): LiteTool = object : LiteTool {
|
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 describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
|
||||||
override fun invoke(arguments: String) = "ok:$name"
|
override fun invoke(arguments: String) = "ok:$name"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.cli)
|
implementation(libs.kotlinx.cli)
|
||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
}
|
}
|
||||||
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
|
||||||
// весь код, включая platformEnv, лежит в commonMain.
|
// весь код, включая platformEnv, лежит в commonMain.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import pw.binom.agentik.client.applyAgentikDefaults
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-клиент CLI: движок CIO + конфигурация agentik.
|
||||||
|
*
|
||||||
|
* Движок выбирается здесь, а не в `:client`: библиотека не выбирает транспорт за
|
||||||
|
* потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64)
|
||||||
|
* покрываются CIO.
|
||||||
|
*
|
||||||
|
* `requestTimeout = 0` — отключение встроенного request-таймаута CIO;
|
||||||
|
* defense-in-depth против обрыва долгих SSE-idle (основная защита —
|
||||||
|
* `noSseReadTimeout` в `:client`).
|
||||||
|
*
|
||||||
|
* [token] = `null` — авторизация выключена.
|
||||||
|
*/
|
||||||
|
internal fun defaultCliHttpClient(token: String? = null): HttpClient =
|
||||||
|
HttpClient(CIO) {
|
||||||
|
applyAgentikDefaults(token)
|
||||||
|
engine { requestTimeout = 0 }
|
||||||
|
}
|
||||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
|||||||
|
|
||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
|
||||||
val id by argument(ArgType.String, description = "ID диалога")
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val ok = agent.deleteConversation(id)
|
val ok = agent.deleteConversation(id)
|
||||||
if (ok) println("deleted: $id") else println("conversation not found: $id")
|
if (ok) println("deleted: $id") else println("conversation not found: $id")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
|
|||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.cli.default
|
import kotlinx.cli.default
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов а
|
|||||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
|
||||||
if (convs.isEmpty()) {
|
if (convs.isEmpty()) {
|
||||||
println("(no conversations)")
|
println("(no conversations)")
|
||||||
|
|||||||
+2
-1
@@ -3,13 +3,14 @@ package pw.binom.agentik.cli.commands
|
|||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.cli.default
|
import kotlinx.cli.default
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
|
||||||
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val conv = agent.createConversation(temp = temp)
|
val conv = agent.createConversation(temp = temp)
|
||||||
println(conv.id)
|
println(conv.id)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ package pw.binom.agentik.cli.commands
|
|||||||
|
|
||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
|
||||||
@@ -9,7 +10,7 @@ class ConvRenameSubcommand : ConvSubcommand("rename", "Переименоват
|
|||||||
val title by argument(ArgType.String, description = "Новое название")
|
val title by argument(ArgType.String, description = "Новое название")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val conv = agent.getConversation(id) ?: run {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
|||||||
|
|
||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
|
||||||
val id by argument(ArgType.String, description = "ID диалога")
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val conv = agent.getConversation(id) ?: run {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
+2
-1
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
|
|||||||
|
|
||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
|
||||||
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
|
||||||
val id by argument(ArgType.String, description = "ID диалога")
|
val id by argument(ArgType.String, description = "ID диалога")
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val conv = agent.getConversation(id) ?: run {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
|
|||||||
import kotlinx.cli.ArgType
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.cli.default
|
import kotlinx.cli.default
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
@@ -13,7 +14,7 @@ class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообще
|
|||||||
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val conv = agent.getConversation(id) ?: run {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.flow.takeWhile
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.agentik.cli.AgentikSubcommand
|
import pw.binom.agentik.cli.AgentikSubcommand
|
||||||
|
import pw.binom.agentik.cli.defaultCliHttpClient
|
||||||
import pw.binom.agentik.client.AgentikAgent
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
|
||||||
@@ -17,7 +18,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
|
||||||
|
|
||||||
override fun execute() = kotlinx.coroutines.runBlocking {
|
override fun execute() = kotlinx.coroutines.runBlocking {
|
||||||
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
|
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
|
||||||
val conv = agent.getConversation(id) ?: run {
|
val conv = agent.getConversation(id) ?: run {
|
||||||
println("conversation not found: $id")
|
println("conversation not found: $id")
|
||||||
return@runBlocking
|
return@runBlocking
|
||||||
@@ -26,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
// Подписываемся на поток событий ДО send: события, отправленные
|
// Подписываемся на поток событий ДО send: события, отправленные
|
||||||
// до подписки, не реплеятся (shared-flow без replay).
|
// до подписки, не реплеятся (shared-flow без replay).
|
||||||
val eventsJob = launch {
|
val eventsJob = launch {
|
||||||
conv.events(Instant.DISTANT_PAST)
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||||
// onEach печатает и терминальный event, takeWhile лишь
|
// onEach печатает и терминальный event, takeWhile лишь
|
||||||
// завершает сбор после него.
|
// завершает сбор после него.
|
||||||
|
.map { it.event }
|
||||||
.onEach { ev -> emit(ev) }
|
.onEach { ev -> emit(ev) }
|
||||||
.takeWhile { ev -> !isTerminal(ev) }
|
.takeWhile { ev -> !isTerminal(ev) }
|
||||||
.collect { }
|
.collect { }
|
||||||
@@ -52,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
|||||||
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||||
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||||
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||||
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
|
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
|
||||||
is Event.End -> println("event End")
|
is Event.End -> println("event End")
|
||||||
is Event.Interrupted -> println("event Interrupted")
|
is Event.Interrupted -> println("event Interrupted")
|
||||||
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.launch
|
|||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -46,7 +46,11 @@ internal class TuiBackend(
|
|||||||
state.postSystem("подключено к ${state.config.server}")
|
state.postSystem("подключено к ${state.config.server}")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
// agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
|
||||||
|
// распаковываем .event для получения AgentEvent (раньше был
|
||||||
|
// отдельный метод agent.events(), теперь упразднён — события
|
||||||
|
// живут в outbox-сущности).
|
||||||
|
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
|
||||||
} catch (_: kotlinx.coroutines.CancellationException) {
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
// штатная отмена при закрытии UI
|
// штатная отмена при закрытии UI
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -88,12 +92,12 @@ internal class TuiBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подписывается на [Conversation.events] и перенаправляет их в [state].
|
* Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
|
||||||
*/
|
*/
|
||||||
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||||
eventsJob?.cancel()
|
eventsJob?.cancel()
|
||||||
eventsJob = scope.launch {
|
eventsJob = scope.launch {
|
||||||
conv.events(from).collect { ev -> dispatch(ev) }
|
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package pw.binom.agentik.tui
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -25,6 +27,19 @@ internal class FakeAgent(
|
|||||||
private set
|
private set
|
||||||
val conversations = mutableListOf<FakeConversation>()
|
val conversations = mutableListOf<FakeConversation>()
|
||||||
|
|
||||||
|
// Storage handles не используются тестами TuiBackend — тесты проверяют
|
||||||
|
// маршрутизацию Conversation.events в UI state. Outbox stub-ы возвращают
|
||||||
|
// emptyFlow, journal — error-on-access (никто не должен его трогать).
|
||||||
|
override val journal: JournalStore = error("journal not used in TuiBackend tests")
|
||||||
|
override val outbox: OutboxStore = object : OutboxStore {
|
||||||
|
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
|
||||||
|
override 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 {
|
override fun createConversation(temp: Boolean): Conversation {
|
||||||
createCount++
|
createCount++
|
||||||
val c = conversationFactory()
|
val c = conversationFactory()
|
||||||
@@ -38,10 +53,7 @@ internal class FakeAgent(
|
|||||||
override suspend fun deleteConversation(id: String): Boolean =
|
override suspend fun deleteConversation(id: String): Boolean =
|
||||||
conversations.removeAll { it.id == id }
|
conversations.removeAll { it.id == id }
|
||||||
|
|
||||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
|
override suspend fun renameConversation(id: String, title: String?): Instant? = null
|
||||||
conversations.toList()
|
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.outbox.Event
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
@@ -170,7 +170,7 @@ class TuiBackendTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
val now = kotlin.time.Clock.System.now()
|
val now = kotlin.time.Clock.System.now()
|
||||||
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
||||||
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
|
conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||||
|
|||||||
+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-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
|
||||||
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
|
||||||
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
|
||||||
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
|
"storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
|
||||||
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
|
"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); переиспользуемое ядро.",
|
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
|
||||||
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
|
"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)."
|
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
|
||||||
|
|||||||
+477
-59
@@ -1,101 +1,519 @@
|
|||||||
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
|
# `:client` — Ktor-клиент к `:server` (KMP, jvm + native)
|
||||||
|
|
||||||
## Что это
|
Тонкий HTTP-клиент к `:server`-фасаду + локальные примитивы, чтобы
|
||||||
|
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
|
||||||
|
про HTTP, JSON, SSE и lifecycle `Conversation`.
|
||||||
|
|
||||||
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
|
## Что есть
|
||||||
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
|
|
||||||
интерфейсы `:proto`:
|
|
||||||
|
|
||||||
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
|
- `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
|
||||||
- `AgentClient` — список и lifecycle диалогов.
|
`Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
|
||||||
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
|
из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
|
||||||
`getMessages()`, `rename()`, `close()`.
|
- `Agent`: `createConversation` / `getConversation` / `getConversations` /
|
||||||
- Внутренний парсер SSE → `Flow<Event>`.
|
`deleteConversation` / `journal` / `outbox` / `close`.
|
||||||
|
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
|
||||||
|
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
|
||||||
|
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
|
||||||
|
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
|
||||||
|
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
|
||||||
|
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
|
||||||
|
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
|
||||||
|
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
|
||||||
|
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
|
||||||
|
с основным потоком событий. См. ниже.
|
||||||
|
|
||||||
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
|
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||||
с теми же типами, что и сервер. Один и тот же клиент работает на
|
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
|
||||||
JVM, iOS, macOS, Linux, Windows.
|
|
||||||
|
|
||||||
## Где используется
|
## Подключение
|
||||||
|
|
||||||
- `:agentik-cli` — REPL.
|
|
||||||
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
|
|
||||||
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
|
|
||||||
|
|
||||||
## Как подключить
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// build.gradle.kts
|
// build.gradle.kts
|
||||||
kotlin {
|
dependencies {
|
||||||
sourceSets.commonMain.dependencies {
|
api("pw.binom.agentik:client:0.1.0")
|
||||||
api("pw.binom.agentik:client:0.1.0")
|
// Движок — на твой выбор (один из):
|
||||||
|
implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native
|
||||||
|
implementation("io.ktor:ktor-client-okhttp:3.x") // JVM
|
||||||
|
implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS
|
||||||
|
// Опционально — только если будешь использовать `InMemoryJournalStore`
|
||||||
|
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
|
||||||
|
api("pw.binom.agentik:journal-inmemory:0.1.0")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что клиент хранит локально (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 пример: создаём агента, открываем диалог,
|
||||||
|
отправляем сообщение, печатаем streaming-ответ.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
fun main() = runBlocking {
|
||||||
|
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
|
||||||
|
val agent = AgentikAgent(
|
||||||
|
id = "my-client",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
token = "s3cret", // или null, если не нужен
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Открыть диалог, отправить сообщение.
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
conv.send(listOf(Content.Text("Привет")))
|
||||||
|
|
||||||
|
// 3. Собирать streaming-ответ.
|
||||||
|
conv.events(after = Clock.System.now()).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is Event.StartResponse -> println("[start]")
|
||||||
|
is Event.AppendText -> print(ev.body)
|
||||||
|
is Event.End -> println("[end]")
|
||||||
|
is Event.Error -> println("[error: ${ev.message}]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Чистый shutdown.
|
||||||
|
conv.close()
|
||||||
|
agent.close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
|
||||||
|
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
|
||||||
|
|
||||||
|
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
|
||||||
|
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
|
||||||
|
|
||||||
|
### Добавить локальный кэш истории (ещё 4 строки)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
|
||||||
|
val cache = InMemoryJournalStore()
|
||||||
|
|
||||||
|
// Backfill + live-refresh в одном фоне:
|
||||||
|
launch {
|
||||||
|
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
|
||||||
|
.collect { cache.append(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ваш код:
|
// История — теперь из кэша, без HTTP:
|
||||||
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
|
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
val conv = agent.createConversation(title = "test")
|
history.forEach { rec ->
|
||||||
conv.send(listOf(Content.Text("hello"))).collect { event ->
|
when (rec) {
|
||||||
when (event) {
|
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
|
||||||
is Event.AppendText -> print(event.body)
|
is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
|
||||||
is Event.End -> println("\n--- end ---")
|
is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
|
||||||
is Event.Error -> error("agent error: ${event.message}")
|
is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
|
||||||
else -> Unit
|
is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Версии
|
Шаблон "remote.listFlow → local.append" работает с любым
|
||||||
|
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
|
||||||
|
"без геморроя".
|
||||||
|
|
||||||
`gradle/libs.versions.toml` → `[versions] agentik-client`.
|
### Что вообще не нужно писать самому
|
||||||
|
|
||||||
Поддерживает все KMP-таргеты, что и `:proto`.
|
- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует
|
||||||
|
`agentikJson` и `InstantSerializer`.
|
||||||
|
- SSE-парсер — `readSse()` внутри `:client`.
|
||||||
|
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
|
||||||
|
`JournalStore.listFlow` сама пагинирует.
|
||||||
|
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
|
||||||
|
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
|
||||||
|
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
|
||||||
|
- Движковые настройки (requestTimeout и пр.) — `HttpClient(engineFactory) { ... }`
|
||||||
|
создаётся здесь; для нестандартных движковых настроек используй
|
||||||
|
`agentikHttpClient(engineFactory, token)` напрямую (он экспортирован).
|
||||||
|
|
||||||
## Примеры API
|
### Что нужно написать самому
|
||||||
|
|
||||||
|
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
|
||||||
|
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
|
||||||
|
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
|
||||||
|
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
|
||||||
|
|
||||||
|
|
||||||
|
## Базовый пример: send + collect events
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
// список диалогов
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
agent.getConversations().collect { println(it.id to it.title) }
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
|
||||||
// live-подписка на события отдельного диалога
|
val agent = AgentikAgent(
|
||||||
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
|
id = "agentik",
|
||||||
|
baseUrl = "http://localhost:8080/agentik",
|
||||||
|
engineFactory = CIO,
|
||||||
|
)
|
||||||
|
|
||||||
// прерывание текущего хода
|
val conv = agent.createConversation(temp = false)
|
||||||
conversation.interrupt()
|
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
|
||||||
|
|
||||||
// история
|
conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
|
||||||
conversation.getMessages(offset = 0).collect { msg ->
|
when (ev) {
|
||||||
when (msg) {
|
is Event.AppendText -> print(ev.body) // streaming чанки
|
||||||
is Message.UserMessage -> println("user: ${msg.content}")
|
is Event.End -> println("\n--- end ---")
|
||||||
is Message.AssistantMessage -> println("assistant: ${msg.content}")
|
is Event.Error -> error("agent error: ${ev.message}")
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## История с локальным кэшем
|
||||||
|
|
||||||
|
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
|
||||||
|
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
|
||||||
|
чтение истории — из локального кэша.
|
||||||
|
|
||||||
|
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
|
||||||
|
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
|
||||||
|
реализовывал интерфейс.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ChatSession(
|
||||||
|
private val agent: pw.binom.agentik.proto.Agent,
|
||||||
|
val conversationId: String,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
|
||||||
|
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
|
||||||
|
// (модуль `:journal-api`).
|
||||||
|
val cache = InMemoryJournalStore()
|
||||||
|
|
||||||
|
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
|
||||||
|
private val scope = kotlinx.coroutines.CoroutineScope(
|
||||||
|
kotlinx.coroutines.SupervisorJob() +
|
||||||
|
kotlinx.coroutines.Dispatchers.Default,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// 1. Backfill: забираем всю историю разговора с сервера.
|
||||||
|
scope.launch {
|
||||||
|
agent.journal.listFlow(
|
||||||
|
conversationId = conversationId,
|
||||||
|
after = Instant.DISTANT_PAST,
|
||||||
|
).collect { cache.append(it) }
|
||||||
|
}
|
||||||
|
// 2. Live: на каждом `End` хода просим у сервера новые записи.
|
||||||
|
scope.launch {
|
||||||
|
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||||
|
if (ev is Event.End) {
|
||||||
|
val newest = cache.let {
|
||||||
|
// last-seen курсор — последний createdAt в кэше
|
||||||
|
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
|
||||||
|
?: Instant.DISTANT_PAST
|
||||||
|
}
|
||||||
|
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
|
||||||
|
.forEach { cache.append(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun history() = kotlinx.coroutines.runBlocking {
|
||||||
|
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Использование:
|
||||||
|
val session = ChatSession(agent, conv.id)
|
||||||
|
|
||||||
|
// История — из кэша:
|
||||||
|
session.history().forEach { rec ->
|
||||||
|
when (rec) {
|
||||||
|
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
|
||||||
|
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
|
||||||
|
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
|
||||||
|
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
|
||||||
|
is MessageRecord.Error -> println("error: ${rec.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправить новое сообщение:
|
||||||
|
session.scope.launch {
|
||||||
|
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
|
||||||
|
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
|
||||||
|
`rec is MessageRecord.UserMessage` для реплик пользователя,
|
||||||
|
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
|
||||||
|
|
||||||
|
## Кэш списка бесед
|
||||||
|
|
||||||
|
`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()` и
|
||||||
|
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
|
||||||
|
только для UI-feedback во время хода. После `End` хода запись уже
|
||||||
|
появится в кэше через refresh-блок выше.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.proto.Event
|
||||||
|
|
||||||
|
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
|
||||||
|
when (ev) {
|
||||||
|
is Event.StartResponse -> println("[start]")
|
||||||
|
is Event.AppendText -> print(ev.body)
|
||||||
|
is Event.AppendImage -> showImage(ev.body)
|
||||||
|
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
|
||||||
|
is Event.ToolResult -> println("[result]")
|
||||||
|
is Event.End -> println("[end]")
|
||||||
|
is Event.Error -> println("[error: ${ev.message}]")
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Прерывание хода
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
agent.getConversation(convId)!!.interrupt()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-conversation
|
||||||
|
|
||||||
|
Один `Agent`, много `ChatSession`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val sessions = mutableMapOf<String, ChatSession>()
|
||||||
|
|
||||||
|
fun open(convId: String): ChatSession =
|
||||||
|
sessions.getOrPut(convId) { ChatSession(agent, convId) }
|
||||||
|
|
||||||
|
fun close(convId: String) {
|
||||||
|
sessions.remove(convId)?.close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
|
||||||
|
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
|
||||||
|
|
||||||
|
## Где `:client` НЕ помогает
|
||||||
|
|
||||||
|
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
|
||||||
|
отдаёт типы и потоки.
|
||||||
|
- **Персистентность кэша** — `InMemoryJournalStore` и
|
||||||
|
`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() {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```
|
```
|
||||||
./gradlew :client:jvmTest
|
./gradlew :client:jvmTest
|
||||||
```
|
```
|
||||||
|
|
||||||
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
|
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
|
||||||
401/404.
|
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
|
||||||
|
reconnect / exhausted attempts → Failed / close → cancel).
|
||||||
|
|
||||||
## Чего здесь НЕТ
|
## Auto-reconnect для живого outbox
|
||||||
|
|
||||||
- Никакого LLM-кода. Это просто клиент.
|
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
|
||||||
- Никакого persistent state. История хранится у сервера, клиент её
|
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
|
||||||
запрашивает через `getMessages` или подписывается через `events`.
|
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
|
||||||
|
|
||||||
## Текущий статус
|
```kotlin
|
||||||
|
val recon = ReconnectingOutbox(
|
||||||
|
outbox = agent.outbox, // или HttpEventStore
|
||||||
|
scope = myScreenScope,
|
||||||
|
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
|
||||||
|
)
|
||||||
|
|
||||||
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
|
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
|
||||||
но клиент совместим с любым сервером, который держит wire-контракт
|
scope.launch {
|
||||||
`:server`.
|
recon.connectionStatus().collect { status ->
|
||||||
|
when (status) {
|
||||||
|
is Connecting -> ui.showBanner("connecting...")
|
||||||
|
is Connected -> ui.hideBanner()
|
||||||
|
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
|
||||||
|
is Failed -> ui.showError(status.cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// На выходе (например, navigation back):
|
||||||
|
recon.close() // отменяет background-loop, потоки терминируются
|
||||||
|
```
|
||||||
|
|
||||||
|
Два потока **независимы** — `events()` содержит только `CommonEvent`,
|
||||||
|
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
|
||||||
|
"мешающего" `Connecting`/`Disconnected` в потоке событий.
|
||||||
|
|
||||||
|
Параметры backoff (см. `BackoffPolicy`):
|
||||||
|
- `initial` / `max` — границы задержки
|
||||||
|
- `multiplier` — множитель на каждом шаге
|
||||||
|
- `jitter` — рандом-разброс (по умолчанию 20%)
|
||||||
|
- `maxAttempts` — лимит попыток; после — `Failed` + закрытие потока
|
||||||
|
|
||||||
|
Если нужен фиксированный delay для тестов — `BackoffPolicy.Fixed(10.milliseconds, attempts = 3)`.
|
||||||
|
|
||||||
## Известное ограничение
|
## Известное ограничение
|
||||||
|
|
||||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
||||||
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
|
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
||||||
terminal (TTY). Это upstream-особенность Ktor SSE.
|
terminal (TTY). Это upstream-особенность Ktor SSE.
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":proto"))
|
api(project(":proto"))
|
||||||
|
api(project(":outbox-api"))
|
||||||
|
api(project(":journal-api"))
|
||||||
|
implementation(project(":journal-inmemory"))
|
||||||
|
|
||||||
implementation(libs.ktor.client.core)
|
api(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.cio)
|
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.test.host)
|
implementation(libs.ktor.server.test.host)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
implementation(libs.ktor.server.sse)
|
implementation(libs.ktor.server.sse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,39 +4,42 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.prepareGet
|
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.proto.Agent
|
||||||
import pw.binom.agentik.proto.AgentEvent
|
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
|
||||||
*
|
*
|
||||||
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
|
* HttpClient создаётся внутри из переданного engine и закрывается в [close].
|
||||||
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
|
*
|
||||||
* POST `/conversations`. Используем `runBlocking` — это одноразовая
|
* **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
|
||||||
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
|
* views на серверные хранилища. Запись — только через команды
|
||||||
* решает, что делать.
|
* [createConversation] / [deleteConversation] / [renameConversation].
|
||||||
*/
|
*/
|
||||||
internal class AgentClient(
|
internal class AgentClient(
|
||||||
private val httpClient: HttpClient,
|
|
||||||
private val baseUrl: String,
|
|
||||||
override val id: String,
|
override val id: String,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: HttpClient,
|
||||||
) : Agent {
|
) : Agent {
|
||||||
|
|
||||||
private val agentUrl: String = baseUrl.trimEnd('/')
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
|
||||||
|
|
||||||
override fun createConversation(temp: Boolean): Conversation =
|
override fun createConversation(temp: Boolean): Conversation =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
|
||||||
@@ -58,24 +61,17 @@ internal class AgentClient(
|
|||||||
return response.status == HttpStatusCode.NoContent
|
return response.status == HttpStatusCode.NoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
|
override suspend fun renameConversation(id: String, title: String?): Instant? {
|
||||||
val snapshots = httpClient.get("$agentUrl/conversations") {
|
val response = httpClient.patch("$agentUrl/conversations/$id") {
|
||||||
parameter("offset", offset)
|
contentType(ContentType.Application.Json)
|
||||||
parameter("limit", limit)
|
setBody(RequestRename(title))
|
||||||
}.body<List<ConversationSnapshot>>()
|
}
|
||||||
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
|
if (response.status == HttpStatusCode.NotFound) return null
|
||||||
|
val rec = response.body<pw.binom.agentik.journal.ConversationRecord>()
|
||||||
|
return rec.updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
override fun close() {
|
||||||
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
httpClient.close()
|
||||||
.execute { response ->
|
|
||||||
check(response.status == HttpStatusCode.OK) {
|
|
||||||
"events: server returned ${response.status}"
|
|
||||||
}
|
|
||||||
readSse(response.bodyAsChannel())
|
|
||||||
.collect { payload ->
|
|
||||||
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,173 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
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 pw.binom.agentik.proto.Agent
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
|
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
|
||||||
* (модуль `:server`).
|
*
|
||||||
|
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
|
||||||
|
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
|
||||||
|
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
|
||||||
|
* снаружи — всё под капотом.
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* val client = AgentikAgent(
|
* val agent = AgentikAgent(
|
||||||
* id = "my-agent",
|
* id = "my-client",
|
||||||
* baseUrl = "http://localhost:8080/agentik",
|
* baseUrl = "http://localhost:8080/agentik",
|
||||||
|
* engineFactory = CIO,
|
||||||
|
* token = "s3cret",
|
||||||
* )
|
* )
|
||||||
* val conv = client.createConversation(temp = false)
|
* val conv = agent.createConversation(temp = false)
|
||||||
* conv.send(listOf(Content.Text("hi")))
|
* conv.send(listOf(Content.Text("hi")))
|
||||||
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
|
* agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||||
|
* .map { it.event }
|
||||||
|
* .collect { ... }
|
||||||
|
* agent.close() // закрывает HttpClient + локальный кэш
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
|
* ## Что клиент должен хранить локально (persistence)
|
||||||
* агента не знает, поэтому клиент должен её знать сам (или взять из
|
|
||||||
* конфига).
|
|
||||||
*
|
*
|
||||||
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
|
* Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
|
||||||
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
|
* 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 + локальный кэш + background-coroutine (идемпотентно).
|
||||||
|
* После этого `createConversation` / `getConversation` etc. не определены.
|
||||||
*/
|
*/
|
||||||
fun AgentikAgent(
|
fun AgentikAgent(
|
||||||
id: String,
|
id: String,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
token: String? = null,
|
token: String? = null,
|
||||||
httpClient: HttpClient = defaultAgentikHttpClient(token),
|
): Agent {
|
||||||
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
|
val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
|
||||||
|
val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient)
|
||||||
|
return wrapWithLocalConversationCache(client, scopeClient = client)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
|
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
|
||||||
* живёт в общем коде и плагина `SSEClientContent` не требует.
|
* локальным in-memory кэшем, синхронизированным с удалённым стором
|
||||||
|
* через outbox-события.
|
||||||
*
|
*
|
||||||
* **Платформы:**
|
* - **Seed**: при создании делает один snapshot через
|
||||||
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
|
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
|
||||||
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
|
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
|
||||||
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
|
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
|
||||||
* `ConversationClient.events`/`AgentClient.events` уже ставят
|
|
||||||
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
|
|
||||||
*
|
*
|
||||||
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
|
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
|
||||||
* Реализация — в [HttpClientFactory.kt].
|
* (на 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.parameter
|
||||||
import io.ktor.client.request.patch
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.prepareGet
|
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
import pw.binom.agentik.proto.MessageContext
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -72,22 +66,6 @@ internal class ConversationClient(
|
|||||||
httpClient.post("$convUrl/interrupt")
|
httpClient.post("$convUrl/interrupt")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<Event> = flow {
|
|
||||||
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
|
|
||||||
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
|
|
||||||
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
|
|
||||||
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
|
|
||||||
.execute { response ->
|
|
||||||
check(response.status == HttpStatusCode.OK) {
|
|
||||||
"events: server returned ${response.status}"
|
|
||||||
}
|
|
||||||
readSse(response.bodyAsChannel())
|
|
||||||
.collect { payload ->
|
|
||||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||||
httpClient.get("$convUrl/messages") {
|
httpClient.get("$convUrl/messages") {
|
||||||
parameter("after", after.toString())
|
parameter("after", after.toString())
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ data class ConversationSnapshot(
|
|||||||
internal data class RequestCreateConversation(val temp: Boolean)
|
internal data class RequestCreateConversation(val temp: Boolean)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class RequestRename(val title: String)
|
internal data class RequestRename(val title: String?)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
import io.ktor.client.plugins.DefaultRequest
|
import io.ktor.client.plugins.DefaultRequest
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
@@ -9,19 +9,20 @@ import io.ktor.http.HttpHeaders
|
|||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
|
* Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
|
||||||
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
|
|
||||||
*
|
*
|
||||||
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
|
* Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
|
||||||
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
|
* не нужен — он передаёт engine в [AgentikAgent] и получает готовый
|
||||||
|
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
|
||||||
|
* на [pw.binom.agentik.proto.Agent.close].
|
||||||
*
|
*
|
||||||
* При заданном [token] на ВСЕ запросы клиента навешивается
|
* Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
|
||||||
* `Authorization: Bearer <token>` через плагин [DefaultRequest]. Это накрывает
|
* (например, дополнительные нестандартные запросы в обход `Agent` API).
|
||||||
* все 10 REST-вызовов и оба SSE-потока сразу — заголовок живёт на HTTP-клиенте,
|
|
||||||
* а не в отдельных запросах.
|
|
||||||
*/
|
*/
|
||||||
fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO) {
|
fun agentikHttpClient(
|
||||||
engine { requestTimeout = 0 }
|
engineFactory: HttpClientEngineFactory<*>,
|
||||||
|
token: String? = null,
|
||||||
|
): HttpClient = HttpClient(engineFactory) {
|
||||||
install(ContentNegotiation) { json(agentikJson) }
|
install(ContentNegotiation) { json(agentikJson) }
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
install(DefaultRequest) {
|
install(DefaultRequest) {
|
||||||
|
|||||||
@@ -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,132 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]):
|
||||||
|
* - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток
|
||||||
|
* [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes])
|
||||||
|
* - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут:
|
||||||
|
* сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает
|
||||||
|
* `.event` для обратной совместимости с форматом AgentEvent)
|
||||||
|
* - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events`
|
||||||
|
*
|
||||||
|
* Для [conversationEvents] с `conversationId == null` (события всех диалогов)
|
||||||
|
* fallback на default [OutboxStore.conversationEvents] — общий поток
|
||||||
|
* `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и
|
||||||
|
* оптимизировать его отдельно нерационально.
|
||||||
|
*
|
||||||
|
* [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()`
|
||||||
|
* (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть
|
||||||
|
* контрактное значение). Клиент, который полагался на gap detection через
|
||||||
|
* message store, продолжит работать — просто fallback никогда не сработает.
|
||||||
|
*
|
||||||
|
* **Импорты [CommonEvent]/[AgentEvent]/[Event] идут напрямую из
|
||||||
|
* `pw.binom.agentik.outbox`** — typealias'ы в `:proto.CommonEvent` и т.п.
|
||||||
|
* НЕ поддерживают nested-class access (`CommonEvent.Agent` через alias
|
||||||
|
* даёт "Unresolved qualified name"), поэтому приходится использовать
|
||||||
|
* конкретный пакет. Типы идентичны, alias только для удобства внешнего API.
|
||||||
|
*/
|
||||||
|
internal class HttpEventStore(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : OutboxStore {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/outbox/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"events: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
emit(agentikJson.decodeFromString(CommonEvent.serializer(), payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события.
|
||||||
|
* Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`.
|
||||||
|
*/
|
||||||
|
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"agentEvents: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
val event = agentikJson.decodeFromString(AgentEvent.serializer(), payload)
|
||||||
|
emit(CommonEvent.Agent(date = event.date, event = event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override с `conversationId != null` — идём в `/conversations/{id}/events`.
|
||||||
|
* С `null` (события всех диалогов) — fallback на default impl из [EventStore]:
|
||||||
|
* общий `/events/all` + filter.
|
||||||
|
*/
|
||||||
|
override fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String?,
|
||||||
|
): Flow<CommonEvent.Conversation> {
|
||||||
|
if (conversationId == null) {
|
||||||
|
return super.conversationEvents(after, null)
|
||||||
|
}
|
||||||
|
return flow {
|
||||||
|
val url = buildString {
|
||||||
|
append("$agentUrl/conversations/$conversationId/events")
|
||||||
|
if (after != null) append("?after=$after")
|
||||||
|
}
|
||||||
|
httpClient.prepareGet(url) { noSseReadTimeout() }
|
||||||
|
.execute { response ->
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"conversationEvents: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
readSse(response.bodyAsChannel())
|
||||||
|
.collect { payload ->
|
||||||
|
val event = agentikJson.decodeFromString(Event.serializer(), payload)
|
||||||
|
emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* У HTTP-варианта нет своего endpoint'а для earliest-event-date.
|
||||||
|
* Контракт [EventStore.earliestEventDate] для пустого буфера говорит
|
||||||
|
* "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст"
|
||||||
|
* (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`.
|
||||||
|
*/
|
||||||
|
override suspend fun earliestEventDate(): Instant = Clock.System.now()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
|
||||||
|
* ходящая в `:server`-фасад.
|
||||||
|
*
|
||||||
|
* **Endpoint**: `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
|
||||||
|
* (см. [pw.binom.agentik.server.journalRoutes]).
|
||||||
|
*
|
||||||
|
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
|
||||||
|
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
|
||||||
|
* в `:server`'s proto-роутах (который отдаёт project'нутые
|
||||||
|
* [pw.binom.agentik.proto.Message]), здесь клиент получает полный transcript
|
||||||
|
* с tool-call/tool-result/error payload'ами, turn-tokens и context'ом.
|
||||||
|
*
|
||||||
|
* **listFlow** — default cold-flow paging через [list] (N+1 round-trip,
|
||||||
|
* дефолтная реализация из [JournalStore]). Для remote/SQL-backed store'а
|
||||||
|
* это OK: server-side paging + client-side flow compose'ится естественно.
|
||||||
|
*
|
||||||
|
* **Read-only**: [JournalStore] не имеет `append` — запись только через
|
||||||
|
* writer-референс, который ChatAgent держит внутри (тип
|
||||||
|
* `MutableJournalStore`, не выставлен наружу через [pw.binom.agentik.proto.Agent]).
|
||||||
|
*/
|
||||||
|
internal class HttpJournalStore(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
) : JournalStore {
|
||||||
|
|
||||||
|
private val agentUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> {
|
||||||
|
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/messages") {
|
||||||
|
parameter("after", after.toString())
|
||||||
|
parameter("offset", offset)
|
||||||
|
parameter("limit", limit)
|
||||||
|
}
|
||||||
|
check(response.status == HttpStatusCode.OK) {
|
||||||
|
"journal.list: server returned ${response.status}"
|
||||||
|
}
|
||||||
|
return response.body<List<MessageRecord>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
|
import kotlin.concurrent.atomics.AtomicReference
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние подключения к удалённому [OutboxStore]. Эмитится через
|
||||||
|
* [ReconnectingOutbox.connectionStatus] — отдельным потоком, **не**
|
||||||
|
* смешивается с [ReconnectingOutbox.events].
|
||||||
|
*
|
||||||
|
* Типичный цикл:
|
||||||
|
* ```
|
||||||
|
* Connecting(1) → Connected → ... → Disconnected(reason, retryIn) →
|
||||||
|
* Connecting(2) → Connected → ...
|
||||||
|
* ```
|
||||||
|
* При полном исчерпании попыток ([BackoffPolicy.maxAttempts]) —
|
||||||
|
* финальный [Failed].
|
||||||
|
*/
|
||||||
|
sealed interface ConnectionStatus {
|
||||||
|
|
||||||
|
/** Начата попытка подключения (включая первую — `attempt == 1`). */
|
||||||
|
data class Connecting(val attempt: Int) : ConnectionStatus
|
||||||
|
|
||||||
|
/** Получен первый event с сервера после [Connecting] / [Disconnected]. */
|
||||||
|
data class Connected(val since: Instant) : ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стрим оборвался (network error, server close, таймаут). [reason] —
|
||||||
|
* причина, `null` если штатное завершение. [willRetryIn] — через сколько
|
||||||
|
* будет следующая попытка (`null` если [Failed]).
|
||||||
|
*/
|
||||||
|
data class Disconnected(
|
||||||
|
val reason: Throwable?,
|
||||||
|
val willRetryIn: Duration?,
|
||||||
|
) : ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все попытки исчерпаны ([BackoffPolicy.maxAttempts]). Поток [events]
|
||||||
|
* закрывается после этого. Создатель [ReconnectingOutbox] должен
|
||||||
|
* решить, что делать — показать ошибку пользователю, пересоздать
|
||||||
|
* outbox и т.п.
|
||||||
|
*/
|
||||||
|
data class Failed(val cause: Throwable) : ConnectionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Политика backoff для [ReconnectingOutbox]. Параметры:
|
||||||
|
*
|
||||||
|
* - [initial] — задержка перед первой retry-попыткой.
|
||||||
|
* - [max] — потолок задержки (после серии умножений).
|
||||||
|
* - [multiplier] — множитель на каждом шаге (например, `2.0` → 1s, 2s, 4s, 8s, ...).
|
||||||
|
* - [jitter] — доля случайного разброса `[0, jitter]` от текущей задержки
|
||||||
|
* (например, `0.2` = ±20%). Снижает thundering-herd при массовом reconnect.
|
||||||
|
* - [maxAttempts] — лимит попыток. `Int.MAX_VALUE` = бесконечно.
|
||||||
|
*/
|
||||||
|
data class BackoffPolicy(
|
||||||
|
val initial: Duration = 1.seconds,
|
||||||
|
val max: Duration = 30.seconds,
|
||||||
|
val multiplier: Double = 2.0,
|
||||||
|
val maxAttempts: Int = Int.MAX_VALUE,
|
||||||
|
val jitter: Double = 0.2,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(initial > Duration.ZERO) { "initial must be positive" }
|
||||||
|
require(max >= initial) { "max must be >= initial" }
|
||||||
|
require(multiplier >= 1.0) { "multiplier must be >= 1.0" }
|
||||||
|
require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
|
||||||
|
require(jitter in 0.0..1.0) { "jitter must be in [0, 1]" }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** 1s → 2s → 4s → ... → 30s, jitter ±20%, бесконечные попытки. */
|
||||||
|
val Default: BackoffPolicy = BackoffPolicy()
|
||||||
|
|
||||||
|
/** Только для тестов: фиксированные задержки без разброса. */
|
||||||
|
fun Fixed(delay: Duration, attempts: Int = 3): BackoffPolicy =
|
||||||
|
BackoffPolicy(
|
||||||
|
initial = delay,
|
||||||
|
max = delay,
|
||||||
|
multiplier = 1.0,
|
||||||
|
maxAttempts = attempts,
|
||||||
|
jitter = 0.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка над [OutboxStore] с автоматическим reconnect при обрыве стрима.
|
||||||
|
*
|
||||||
|
* **Два независимых потока**:
|
||||||
|
* - [events] — `Flow<CommonEvent>`, тот же контракт что [OutboxStore.events],
|
||||||
|
* но с автоматическим переподключением через [BackoffPolicy]. Cursor
|
||||||
|
* (`lastSeen`) сохраняется между попытками — клиент не теряет события.
|
||||||
|
* - [connectionStatus] — `Flow<ConnectionStatus>`, **параллельный** поток
|
||||||
|
* lifecycle подключения. Не смешивается с [events].
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val outbox = ReconnectingOutbox(httpEventStore, scope)
|
||||||
|
*
|
||||||
|
* scope.launch {
|
||||||
|
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) }
|
||||||
|
* }
|
||||||
|
* scope.launch {
|
||||||
|
* outbox.connectionStatus().collect { s -> ui.showStatus(s) }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // На выходе:
|
||||||
|
* outbox.close() // отменяет background-loop, эмитит Cancelled-как-Disconnected
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Создатель передаёт свой [scope] — жизненный цикл reconnect-цикла
|
||||||
|
* привязан к нему. Закрытие scope (или явный [close]) отменяет
|
||||||
|
* background-loop. После [close] оба flow терминируются.
|
||||||
|
*/
|
||||||
|
class ReconnectingOutbox(
|
||||||
|
private val outbox: OutboxStore,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val policy: BackoffPolicy = BackoffPolicy.Default,
|
||||||
|
private val random: Random = Random.Default,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<CommonEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
private val _status = MutableSharedFlow<ConnectionStatus>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 64,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
|
||||||
|
* учитывается только при первом вызове (любом из [events] /
|
||||||
|
* [connectionStatus]). После reconnect курсор берётся из `date`
|
||||||
|
* последнего виденного события.
|
||||||
|
*
|
||||||
|
* Коллекторы независимы — каждый получает свою копию потока (shared).
|
||||||
|
* Медленный коллектор может пропускать события при переполнении буфера
|
||||||
|
* (`DROP_OLDEST`).
|
||||||
|
*/
|
||||||
|
fun events(after: Instant? = null): Flow<CommonEvent> {
|
||||||
|
ensureStarted(after)
|
||||||
|
return _events
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle подключения: [ConnectionStatus.Connecting] /
|
||||||
|
* [ConnectionStatus.Connected] / [ConnectionStatus.Disconnected] /
|
||||||
|
* [ConnectionStatus.Failed]. **Не смешивается** с [events] — это
|
||||||
|
* отдельный поток для UI-индикации статуса сети.
|
||||||
|
*/
|
||||||
|
fun connectionStatus(): Flow<ConnectionStatus> {
|
||||||
|
ensureStarted(null)
|
||||||
|
return _status
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private fun ensureStarted(initialCursor: Instant?) {
|
||||||
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
lastSeen.store(initialCursor)
|
||||||
|
job = scope.launch { runLoop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
var attempt = 0
|
||||||
|
var connected = false
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
attempt++
|
||||||
|
_status.emit(ConnectionStatus.Connecting(attempt))
|
||||||
|
val error: Throwable? = try {
|
||||||
|
outbox.events(after = lastSeen.load()).collect { event ->
|
||||||
|
lastSeen.store(event.date)
|
||||||
|
_events.emit(event)
|
||||||
|
if (!connected) {
|
||||||
|
connected = true
|
||||||
|
_status.emit(ConnectionStatus.Connected(event.date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (t: CancellationException) {
|
||||||
|
throw t
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
t
|
||||||
|
}
|
||||||
|
connected = false
|
||||||
|
if (attempt >= policy.maxAttempts) {
|
||||||
|
_status.emit(
|
||||||
|
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val backoff = computeBackoff(attempt)
|
||||||
|
_status.emit(ConnectionStatus.Disconnected(error, backoff))
|
||||||
|
delay(backoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeBackoff(attempt: Int): Duration {
|
||||||
|
// attempt 1 → initial, 2 → initial * m, 3 → initial * m^2, ...
|
||||||
|
val base = (policy.initial.inWholeMilliseconds.toDouble() *
|
||||||
|
policy.multiplier.pow((attempt - 1).toDouble()))
|
||||||
|
.toLong()
|
||||||
|
val capped = min(base, policy.max.inWholeMilliseconds)
|
||||||
|
val jitterMs = (capped * policy.jitter * random.nextDouble()).toLong()
|
||||||
|
val finalMs = (capped + jitterMs).coerceAtLeast(1L)
|
||||||
|
return Duration.parse("${finalMs}ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import io.ktor.client.request.HttpRequestBuilder
|
|||||||
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
|
||||||
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
|
||||||
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
|
||||||
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
|
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
|
||||||
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
|
||||||
*
|
*
|
||||||
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package pw.binom.agentik.client
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
@@ -19,7 +21,7 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тесты клиентской части: [defaultAgentikHttpClient] с заданным `token` прикладывает
|
* Тесты клиентской части: [agentikHttpClient] с заданным `token` прикладывает
|
||||||
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
|
||||||
* без токена — заголовок не отправляется.
|
* без токена — заголовок не отправляется.
|
||||||
*
|
*
|
||||||
@@ -47,6 +49,9 @@ class BearerHeaderTest {
|
|||||||
var token: String? = null
|
var token: String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clientWith(token: String?): HttpClient =
|
||||||
|
agentikHttpClient(engineFactory = CIO, token = token)
|
||||||
|
|
||||||
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
|
||||||
val server = embeddedServer(ServerCIO, port = 0) {
|
val server = embeddedServer(ServerCIO, port = 0) {
|
||||||
routing {
|
routing {
|
||||||
@@ -66,7 +71,7 @@ class BearerHeaderTest {
|
|||||||
fun clientWithTokenAttachesBearerHeader() = runBlocking {
|
fun clientWithTokenAttachesBearerHeader() = runBlocking {
|
||||||
val (server, port) = startServer()
|
val (server, port) = startServer()
|
||||||
try {
|
try {
|
||||||
val client = defaultAgentikHttpClient("secret")
|
val client = clientWith("secret")
|
||||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
assertEquals(HttpStatusCode.OK, resp.status)
|
assertEquals(HttpStatusCode.OK, resp.status)
|
||||||
assertEquals("[]", resp.bodyAsText())
|
assertEquals("[]", resp.bodyAsText())
|
||||||
@@ -79,7 +84,7 @@ class BearerHeaderTest {
|
|||||||
fun clientWithoutTokenGets401(): Unit = runBlocking {
|
fun clientWithoutTokenGets401(): Unit = runBlocking {
|
||||||
val (server, port) = startServer()
|
val (server, port) = startServer()
|
||||||
try {
|
try {
|
||||||
val client = defaultAgentikHttpClient(null)
|
val client = clientWith(null)
|
||||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,7 +96,7 @@ class BearerHeaderTest {
|
|||||||
fun clientWithWrongTokenGets401(): Unit = runBlocking {
|
fun clientWithWrongTokenGets401(): Unit = runBlocking {
|
||||||
val (server, port) = startServer()
|
val (server, port) = startServer()
|
||||||
try {
|
try {
|
||||||
val client = defaultAgentikHttpClient("wrong")
|
val client = clientWith("wrong")
|
||||||
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
|
||||||
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
assertEquals(HttpStatusCode.Unauthorized, resp.status)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package pw.binom.agentik.client
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.OutboxStore
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [OutboxStore] для unit-тестов [ReconnectingOutbox].
|
||||||
|
*
|
||||||
|
* Управление:
|
||||||
|
* - [push] — кладёт [CommonEvent] в очередь, флоу доставит.
|
||||||
|
* - [throwAtNextEvent] — следующий «тик» `events(after)` бросит этот Throwable
|
||||||
|
* (симулирует network error / stream break).
|
||||||
|
*
|
||||||
|
* Сигнатура [events] идентична боевой — её можно подменить боевым
|
||||||
|
* `HttpEventStore`, контракт один и тот же.
|
||||||
|
*/
|
||||||
|
internal class FakeOutbox : OutboxStore {
|
||||||
|
private sealed interface Msg {
|
||||||
|
data class Ev(val event: CommonEvent) : Msg
|
||||||
|
data class Err(val throwable: Throwable) : Msg
|
||||||
|
}
|
||||||
|
|
||||||
|
private val channel = Channel<Msg>(Channel.UNLIMITED)
|
||||||
|
|
||||||
|
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||||
|
for (msg in channel) {
|
||||||
|
when (msg) {
|
||||||
|
is Msg.Err -> throw msg.throwable
|
||||||
|
is Msg.Ev -> emit(msg.event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
|
||||||
|
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
|
||||||
|
|
||||||
|
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow()
|
||||||
|
override fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String?,
|
||||||
|
): Flow<CommonEvent.Conversation> = emptyFlow()
|
||||||
|
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||||
|
override fun close() { channel.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testEvent(dateMs: Long): CommonEvent =
|
||||||
|
CommonEvent.Conversation(
|
||||||
|
date = Instant.fromEpochMilliseconds(dateMs),
|
||||||
|
conversationId = "test",
|
||||||
|
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ReconnectingOutboxTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first event after connect emits Connecting then Connected`() = runConnectionTest(
|
||||||
|
attempts = 5,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
val status = ctx.statusLog
|
||||||
|
val events = ctx.eventsLog
|
||||||
|
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
assertEquals(1, status.count { it is ConnectionStatus.Connecting && it.attempt == 1 })
|
||||||
|
assertEquals(1, status.count { it is ConnectionStatus.Connected })
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disconnect mid-stream triggers retry with backoff and resumes from last seen`() =
|
||||||
|
runConnectionTest(attempts = 5) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
val status = ctx.statusLog
|
||||||
|
val events = ctx.eventsLog
|
||||||
|
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
|
||||||
|
// Имитируем обрыв стрима после первого события.
|
||||||
|
fake.throwAtNextEvent(RuntimeException("simulated network error"))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
// После Disconnected должен прийти Connecting(2), затем Connected,
|
||||||
|
// затем новые события без дубля предыдущего.
|
||||||
|
val disconnectedIndex = status.indexOfFirst { it is ConnectionStatus.Disconnected }
|
||||||
|
val connecting2Index = status.indexOfFirst {
|
||||||
|
it is ConnectionStatus.Connecting && it.attempt == 2
|
||||||
|
}
|
||||||
|
assertTrue(disconnectedIndex >= 0, "no Disconnected emitted, got: $status")
|
||||||
|
assertTrue(connecting2Index > disconnectedIndex,
|
||||||
|
"expected Connecting(2) after Disconnected, got: $status")
|
||||||
|
|
||||||
|
// Push a new event with later date — cursor preserves lastSeen.
|
||||||
|
fake.push(testEvent(2000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
|
||||||
|
assertEquals(2, events.size)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
|
||||||
|
assertEquals(Instant.fromEpochMilliseconds(2000), events[1].date)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exhausted attempts emits Failed and closes flow`() = runConnectionTest(
|
||||||
|
attempts = 3,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
|
||||||
|
// Каждая попытка connect бросает — все 3 попытки fail.
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
fake.throwAtNextEvent(RuntimeException("server is dead #${i + 1}"))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
val failed = ctx.statusLog.filterIsInstance<ConnectionStatus.Failed>().firstOrNull()
|
||||||
|
assertNotNull(failed) { "expected Failed status, got: ${ctx.statusLog}" }
|
||||||
|
assertTrue(failed.cause is RuntimeException)
|
||||||
|
assertEquals(0, ctx.eventsLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `close cancels background loop`() = runConnectionTest(
|
||||||
|
attempts = 5,
|
||||||
|
) { ctx ->
|
||||||
|
val fake = ctx.fake
|
||||||
|
fake.push(testEvent(1000))
|
||||||
|
ctx.advanceAndDrain(50)
|
||||||
|
assertEquals(1, ctx.eventsLog.size)
|
||||||
|
|
||||||
|
ctx.recon.close()
|
||||||
|
ctx.advanceAndDrain(100)
|
||||||
|
|
||||||
|
// После close запуск новых эмиссий не должен происходить.
|
||||||
|
val beforePush = ctx.eventsLog.size
|
||||||
|
fake.push(testEvent(2000))
|
||||||
|
ctx.advanceAndDrain(100)
|
||||||
|
assertEquals(beforePush, ctx.eventsLog.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TestCtx(
|
||||||
|
val fake: FakeOutbox,
|
||||||
|
val recon: ReconnectingOutbox,
|
||||||
|
val statusLog: MutableList<ConnectionStatus>,
|
||||||
|
val eventsLog: MutableList<CommonEvent>,
|
||||||
|
val jobs: List<Job>,
|
||||||
|
val scope: CoroutineScope,
|
||||||
|
val advanceAndDrain: (Long) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает [ReconnectingOutbox] с policy из `attempts` попыток по 10ms,
|
||||||
|
* сабскрайбит на оба потока в собирающие лист, и возвращает [TestCtx]
|
||||||
|
* с управляемым `advanceAndDrain(ms)` — прокрутить виртуальное время.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private fun runConnectionTest(
|
||||||
|
attempts: Int,
|
||||||
|
block: suspend (TestCtx) -> Unit,
|
||||||
|
) = runTest {
|
||||||
|
val policy = BackoffPolicy.Fixed(
|
||||||
|
delay = Duration.parse("10ms"),
|
||||||
|
attempts = attempts,
|
||||||
|
)
|
||||||
|
val fake = FakeOutbox()
|
||||||
|
val recon = ReconnectingOutbox(
|
||||||
|
outbox = fake,
|
||||||
|
scope = this,
|
||||||
|
policy = policy,
|
||||||
|
)
|
||||||
|
val statusLog = mutableListOf<ConnectionStatus>()
|
||||||
|
val eventsLog = mutableListOf<CommonEvent>()
|
||||||
|
val jobs = listOf(
|
||||||
|
launch { recon.connectionStatus().collect { statusLog.add(it) } },
|
||||||
|
launch { recon.events().collect { eventsLog.add(it) } },
|
||||||
|
)
|
||||||
|
val advanceAndDrain: (Long) -> Unit = { ms ->
|
||||||
|
if (ms > 0) advanceTimeBy(ms)
|
||||||
|
runCurrent()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
TestCtx(fake, recon, statusLog, eventsLog, jobs, this, advanceAndDrain).also { block(it) }
|
||||||
|
} finally {
|
||||||
|
recon.close()
|
||||||
|
jobs.forEach { it.cancel() }
|
||||||
|
fake.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API для runtime context агента (compaction, order_idx, summary entries).
|
||||||
|
// Зависит от :journal-api для типов `Content` / `MessageContext` (audit-log
|
||||||
|
// payload'ы, которые рабочая память ссылает).
|
||||||
|
//
|
||||||
|
// НЕ нужен тонким клиентам — только серверному рантайму (`:standalone`, `:agentik-cli`,
|
||||||
|
// будущий `:android-agent` core).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.context
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
|
||||||
* Агент не должен с этим типом работать напрямую — он работает с
|
* Агент не должен с этим типом работать напрямую — он работает с
|
||||||
* [WorkingMemoryEntry] через [WorkingMemoryStore].
|
* [WorkingMemoryEntry] через [ContextStore].
|
||||||
*/
|
*/
|
||||||
data class WorkingMemoryRow(
|
data class WorkingMemoryRow(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
|
|||||||
*
|
*
|
||||||
* Суммаризация / чистка — один атомарный вызов [compact].
|
* Суммаризация / чистка — один атомарный вызов [compact].
|
||||||
*/
|
*/
|
||||||
interface WorkingMemoryStore : AutoCloseable {
|
interface ContextStore : AutoCloseable {
|
||||||
|
|
||||||
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.context
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.journal.MessageContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация :context-api (ContextStore) поверх ksqlite.
|
||||||
|
// Минимальная — только таблица `working_memory` + 2 индекса по ней.
|
||||||
|
// Остальные таблицы (`conversation`, `message`, `reflection`) живут в
|
||||||
|
// других ksqlite-модулях; этот модуль не претендует на полную схему
|
||||||
|
// агента.
|
||||||
|
//
|
||||||
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
|
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
api(project(":context-api"))
|
||||||
|
api(project(":journal-api"))
|
||||||
|
api(project(":reflection-api"))
|
||||||
|
// :context-api ссылается на Content / MessageContext из
|
||||||
|
// :message-log-api (старый canonical). Транзитивно через api,
|
||||||
|
// но фиксируем явно чтобы тестовый код видел Content без
|
||||||
|
// обхода через :context-api.
|
||||||
|
// api(project(":message-log-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.context.ContextStore
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryRow
|
||||||
|
import pw.binom.agentik.journal.Ids
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
|
||||||
|
*
|
||||||
|
* Единственный владелец таблицы `working_memory` в проекте. Используется
|
||||||
|
* напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
|
||||||
|
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
|
||||||
|
*
|
||||||
|
* ## 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 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 }
|
||||||
|
|
||||||
|
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
|
||||||
|
// SIGSEGV в StmtHolder.finalize на закрытой connection).
|
||||||
|
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||||
|
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
ORDER BY ${Schema.COL_ORDER_IDX} ASC
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
|
)
|
||||||
|
private val maxOrderIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
|
||||||
|
FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val dropFromIdxStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val insertSummaryStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
|
||||||
|
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, NULL, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
val newIdx = maxOrderIdx(conversationId) + 1
|
||||||
|
insertStmt.reset()
|
||||||
|
insertStmt.clearBindings()
|
||||||
|
insertStmt.bindText(1, Ids.new("wm"))
|
||||||
|
insertStmt.bindText(2, conversationId)
|
||||||
|
insertStmt.bindLong(3, newIdx)
|
||||||
|
val srcId = entry.sourceMessageId
|
||||||
|
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
|
||||||
|
insertStmt.bindText(5, entryKind(entry))
|
||||||
|
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
|
||||||
|
insertStmt.bindLong(7, now.toEpochMilliseconds())
|
||||||
|
insertStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listStmt.reset()
|
||||||
|
listStmt.clearBindings()
|
||||||
|
listStmt.bindText(1, conversationId)
|
||||||
|
val out = mutableListOf<WorkingMemoryRow>()
|
||||||
|
listStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
out.add(
|
||||||
|
WorkingMemoryRow(
|
||||||
|
id = rs.getText(0)!!,
|
||||||
|
conversationId = rs.getText(1)!!,
|
||||||
|
orderIdx = rs.getLong(2)!!,
|
||||||
|
sourceMessageId = rs.getText(3),
|
||||||
|
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
clearStmt.reset()
|
||||||
|
clearStmt.clearBindings()
|
||||||
|
clearStmt.bindText(1, conversationId)
|
||||||
|
clearStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun compact(
|
||||||
|
dropFromOrderIdx: Long,
|
||||||
|
conversationId: String,
|
||||||
|
summaryText: String?,
|
||||||
|
): Long = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
var newMax = 0L
|
||||||
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
|
val summaryId = Ids.new("wm")
|
||||||
|
connection.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
dropFromIdxStmt.reset()
|
||||||
|
dropFromIdxStmt.clearBindings()
|
||||||
|
dropFromIdxStmt.bindText(1, conversationId)
|
||||||
|
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
|
||||||
|
dropFromIdxStmt.executeUpdate()
|
||||||
|
|
||||||
|
if (!summaryText.isNullOrBlank()) {
|
||||||
|
val afterDelete = maxOrderIdx(conversationId)
|
||||||
|
val newIdx = afterDelete + 1
|
||||||
|
insertSummaryStmt.reset()
|
||||||
|
insertSummaryStmt.clearBindings()
|
||||||
|
insertSummaryStmt.bindText(1, summaryId)
|
||||||
|
insertSummaryStmt.bindText(2, conversationId)
|
||||||
|
insertSummaryStmt.bindLong(3, newIdx)
|
||||||
|
insertSummaryStmt.bindText(4, "summary")
|
||||||
|
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
|
||||||
|
insertSummaryStmt.bindLong(6, nowMs)
|
||||||
|
insertSummaryStmt.executeUpdate()
|
||||||
|
newMax = newIdx
|
||||||
|
} else {
|
||||||
|
newMax = maxOrderIdx(conversationId)
|
||||||
|
}
|
||||||
|
connection.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { connection.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
newMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
insertStmt.close()
|
||||||
|
listStmt.close()
|
||||||
|
clearStmt.close()
|
||||||
|
maxOrderIdxStmt.close()
|
||||||
|
dropFromIdxStmt.close()
|
||||||
|
insertSummaryStmt.close()
|
||||||
|
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 {
|
||||||
|
maxOrderIdxStmt.reset()
|
||||||
|
maxOrderIdxStmt.clearBindings()
|
||||||
|
maxOrderIdxStmt.bindText(1, conversationId)
|
||||||
|
maxOrderIdxStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0) ?: 0L
|
||||||
|
}
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
|
||||||
|
is WorkingMemoryEntry.User -> "user"
|
||||||
|
is WorkingMemoryEntry.Assistant -> "assistant"
|
||||||
|
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
|
||||||
|
is WorkingMemoryEntry.Summary -> "summary"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:context-api`.
|
||||||
|
*
|
||||||
|
* Минимум — только то, что относится к `working_memory` (реализация
|
||||||
|
* [KsqliteContextStore]). Остальные таблицы агента (`conversation`,
|
||||||
|
* `message`, `reflection`) живут в других ksqlite-модулях.
|
||||||
|
*
|
||||||
|
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||||
|
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||||
|
*/
|
||||||
|
object Schema {
|
||||||
|
|
||||||
|
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
|
||||||
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
|
// ───── Таблица ─────
|
||||||
|
const val TABLE_WORKING_MEMORY = "working_memory"
|
||||||
|
|
||||||
|
// ───── Колонки ─────
|
||||||
|
const val COL_ID = "id"
|
||||||
|
const val COL_CONVERSATION_ID = "conversation_id"
|
||||||
|
const val COL_ORDER_IDX = "order_idx"
|
||||||
|
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
|
||||||
|
const val COL_KIND = "kind"
|
||||||
|
const val COL_PAYLOAD_JSON = "payload_json"
|
||||||
|
const val COL_CREATED_AT = "created_at"
|
||||||
|
|
||||||
|
// ───── Индексы ─────
|
||||||
|
const val IDX_WM_UNIQUE = "idx_wm_unique"
|
||||||
|
const val IDX_WM_CONV = "idx_wm_conv"
|
||||||
|
|
||||||
|
private val v1Ddl = """
|
||||||
|
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
|
||||||
|
$COL_ID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
$COL_CONVERSATION_ID TEXT NOT NULL,
|
||||||
|
$COL_ORDER_IDX INTEGER NOT NULL,
|
||||||
|
$COL_SOURCE_MESSAGE_ID TEXT,
|
||||||
|
$COL_KIND TEXT NOT NULL,
|
||||||
|
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||||
|
$COL_CREATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val v1IndexesDdl = """
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
|
||||||
|
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||||
|
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
|
||||||
|
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||||
|
*
|
||||||
|
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
|
||||||
|
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
|
||||||
|
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
|
||||||
|
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
|
||||||
|
*
|
||||||
|
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
|
||||||
|
*/
|
||||||
|
fun migrate(conn: SQLiteConnection) {
|
||||||
|
val current = readUserVersion(conn)
|
||||||
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
|
conn.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
if (current < 1) {
|
||||||
|
conn.exec(v1Ddl)
|
||||||
|
conn.exec(v1IndexesDdl)
|
||||||
|
}
|
||||||
|
// future: if (current < 2) { conn.exec(v2Ddl) }
|
||||||
|
writeUserVersion(conn, CURRENT_VERSION)
|
||||||
|
conn.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||||
|
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
||||||
|
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
|
||||||
|
// поэтому собираем SQL строкой (значение контролируемое, не user input).
|
||||||
|
conn.exec("PRAGMA user_version = $version")
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package pw.binom.agentik.context.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
|
||||||
|
* SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
|
||||||
|
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||||
|
*/
|
||||||
|
class KsqliteContextStoreTest {
|
||||||
|
|
||||||
|
private lateinit var conn: SQLiteConnection
|
||||||
|
private lateinit var store: KsqliteContextStore
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
|
||||||
|
store = KsqliteContextStore(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
store.close()
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User =
|
||||||
|
WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content)))
|
||||||
|
|
||||||
|
private fun asstMsg(content: String): WorkingMemoryEntry.Assistant =
|
||||||
|
WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content)))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendAndListReturnsInOrder() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("first"), t)
|
||||||
|
store.append("c1", asstMsg("reply"), t)
|
||||||
|
val list = store.list("c1")
|
||||||
|
assertEquals(2, list.size)
|
||||||
|
assertEquals(1L, list[0].orderIdx)
|
||||||
|
assertEquals(2L, list[1].orderIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListIsolatesConversations() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("c1-msg"), t)
|
||||||
|
store.append("c2", userMsg("c2-msg"), t)
|
||||||
|
assertEquals(1, store.list("c1").size)
|
||||||
|
assertEquals(1, store.list("c2").size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testClearRemovesAllForConversation() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
store.append("c1", userMsg("b"), t)
|
||||||
|
store.clear("c1")
|
||||||
|
assertEquals(emptyList(), store.list("c1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompactDeletesAndInsertsSummary() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
store.append("c1", userMsg("b"), t)
|
||||||
|
store.append("c1", userMsg("c"), t)
|
||||||
|
// dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a).
|
||||||
|
// Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2.
|
||||||
|
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary")
|
||||||
|
assertEquals(2L, newMax)
|
||||||
|
val remaining = store.list("c1")
|
||||||
|
assertEquals(2, remaining.size)
|
||||||
|
assertEquals(1L, remaining[0].orderIdx)
|
||||||
|
assertEquals(2L, remaining[1].orderIdx)
|
||||||
|
assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompactWithoutSummaryKeepsTailBelow() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
store.append("c1", userMsg("a"), t)
|
||||||
|
// dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1.
|
||||||
|
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
|
||||||
|
assertEquals(1L, newMax)
|
||||||
|
val remaining = store.list("c1")
|
||||||
|
assertEquals(1, remaining.size)
|
||||||
|
assertEquals(1L, remaining[0].orderIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ Main.kt
|
|||||||
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
|
||||||
summarization-вставка отложена (нужен дизайн-проработка).
|
summarization-вставка отложена (нужен дизайн-проработка).
|
||||||
|
|
||||||
**`MessageStore`** — append-only аудит. На каждый ход дописываются
|
**`JournalStore`** — append-only аудит. На каждый ход дописываются
|
||||||
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
|
||||||
update/delete кроме каскада из `ConversationStore.delete`.
|
update/delete кроме каскада из `ConversationStore.delete`.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -175,7 +175,7 @@ fun main() {
|
|||||||
|
|
||||||
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
|
||||||
|
|
||||||
### `MessageStore`
|
### `JournalStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun append(record: MessageRecord)
|
suspend fun append(record: MessageRecord)
|
||||||
@@ -183,7 +183,7 @@ suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int
|
|||||||
suspend fun listAll(conversationId: String): List<MessageRecord>
|
suspend fun listAll(conversationId: String): List<MessageRecord>
|
||||||
```
|
```
|
||||||
|
|
||||||
### `WorkingMemoryStore`
|
### `ContextStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
|
||||||
@@ -194,7 +194,7 @@ suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
|
|||||||
|
|
||||||
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
|
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
|
||||||
|
|
||||||
### `ConversationStore`
|
### `MutableConversationStore`
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
suspend fun upsert(record: ConversationRecord)
|
suspend fun upsert(record: ConversationRecord)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# `:event-store` — bounded-tail event log (KMP)
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Двухуровневое хранилище событий агента. Этот модуль — **короткий
|
||||||
|
bounded tail** для live-SSE и недавнего replay. Полный audit log
|
||||||
|
живёт в `:message-store-api` (никогда не эвиктится, source of truth).
|
||||||
|
|
||||||
|
Три принципа:
|
||||||
|
|
||||||
|
1. **Tail управляет TTL сам.** Никаких `prune`/`cleanup` методов наружу —
|
||||||
|
implementation решает, когда выкинуть старый event. Caller'ы не
|
||||||
|
могут забыть cleanup.
|
||||||
|
2. **Catchup + live в одном Flow.** `events(after)` сначала отдаёт
|
||||||
|
буферизованный диапазон, потом переключается на live tail — клиент
|
||||||
|
не должен знать, где у него "разрыв".
|
||||||
|
3. **Read-only контракт для consumer'ов.** Запись через
|
||||||
|
[MutableEventStore], чтение через [EventStore]. Compile-time
|
||||||
|
гарантия что observer не сможет писать в store.
|
||||||
|
|
||||||
|
## Где используется
|
||||||
|
|
||||||
|
- `:standalone` ChatAgent — append через `MutableOutboxStore` (заменяет
|
||||||
|
текущий `agentEvents: MutableSharedFlow` + `persistAgentEvent`).
|
||||||
|
- `:server` Routes.kt — `/events/all` SSE endpoint читает через
|
||||||
|
`EventStore.events(after)`.
|
||||||
|
- Будущий `:android-agent` core — same интерфейс для локального
|
||||||
|
bounded tail без dedicated server connection.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ :event-store (этот модуль) ────────────────────────┐
|
||||||
|
│ Bounded tail с auto-TTL: │
|
||||||
|
│ • append(event) ← producer │
|
||||||
|
│ • events(after): Flow ← consumer │
|
||||||
|
│ • earliestEventDate() для gap detection │
|
||||||
|
│ TTL/cap eviction — внутри impl │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
▲ gap detected
|
||||||
|
│
|
||||||
|
┌─ :message-store-api (полный audit log) ───────────┐
|
||||||
|
│ MessageStore: query(after, before, limit) │
|
||||||
|
│ Никогда не эвиктится. Source of truth. │
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reconnect pattern** (caller делает):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val earliest = eventStore.earliestEventDate()
|
||||||
|
if (client.lastSeen < earliest) {
|
||||||
|
// gap: догоняем через :message-store-api
|
||||||
|
val gap = messageStore.query(after = client.lastSeen, before = earliest)
|
||||||
|
applyAll(gap)
|
||||||
|
client.lastSeen = gap.last().createdAt
|
||||||
|
}
|
||||||
|
eventStore.events(after = client.lastSeen).collect { apply(it) }
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `OutboxStore` (read-only, для consumer'ов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface EventStore : AutoCloseable {
|
||||||
|
fun events(after: Instant?): Flow<CommonEvent>
|
||||||
|
fun conversationEvents(
|
||||||
|
after: Instant?,
|
||||||
|
conversationId: String? = null, // null = все диалоги
|
||||||
|
): Flow<CommonEvent.Conversation>
|
||||||
|
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent>
|
||||||
|
suspend fun earliestEventDate(): Instant // non-null: now() для пустого буфера
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Семантика фильтров**:
|
||||||
|
- `conversationEvents(null)` — все диалоги.
|
||||||
|
- `conversationEvents("c-123")` — один конкретный диалог.
|
||||||
|
- `agentEvents(...)` — только lifecycle (Created/Deleted/Renamed).
|
||||||
|
|
||||||
|
Все три возвращают **типизированные** subtype'ы [CommonEvent], так что
|
||||||
|
caller'у не нужно `.filterIsInstance` на клиентской стороне.
|
||||||
|
|
||||||
|
### `MutableEventStore : EventStore` (для producer'ов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface MutableEventStore : EventStore {
|
||||||
|
suspend fun append(event: CommonEvent)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
|
||||||
|
retry даст дубликат. Для exactly-once — dedup через
|
||||||
|
`:message-store-api` (там есть монотонный `id`).
|
||||||
|
|
||||||
|
**Silently evicted**: implementation может выкинуть event сразу после
|
||||||
|
append по TTL/cap. Producer не должен полагаться на то, что event
|
||||||
|
дойдёт до клиента, если он вне retention window.
|
||||||
|
|
||||||
|
## Когда использовать какой интерфейс
|
||||||
|
|
||||||
|
| Caller | Method |
|
||||||
|
|---|---|
|
||||||
|
| Server `/events/all` SSE (mixed) | `events(after)` |
|
||||||
|
| Server `/conversations/{id}/events` SSE | `conversationEvents(after, conversationId)` |
|
||||||
|
| Server `/agent/events` SSE (lifecycle only) | `agentEvents(after)` |
|
||||||
|
| Admin dashboard (lifecycle) | `agentEvents(after)` |
|
||||||
|
| Parent orchestrator (mixed) | `events(after)` |
|
||||||
|
| Тесты | `events(after)` + projection через фильтр |
|
||||||
|
|
||||||
|
## Как добавить новый implementation
|
||||||
|
|
||||||
|
1. Создать класс с конструктором и lifecycle (`close()` обязан
|
||||||
|
освободить ресурсы).
|
||||||
|
2. Реализовать минимум: append (с TTL eviction), events (Flow с
|
||||||
|
catchup + live), earliestEventDate (non-null Instant, now() если
|
||||||
|
буфер пуст).
|
||||||
|
3. Для persistent impl: SQL/ksqlite таблица с индексом по date,
|
||||||
|
вставка = `INSERT OR IGNORE` для дедупликации на уровне БД
|
||||||
|
(если в схеме будет id).
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
- ✅ Interface дизайн (`OutboxStore` + `MutableOutboxStore`)
|
||||||
|
- ✅ KMP build (jvm + linuxX64 + mingwX64)
|
||||||
|
- ⏳ Нет implementations (next: `InMemoryEventStore` для тестов)
|
||||||
|
- ⏳ Не интегрирован в `:standalone`/`:server`
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
- `:proto` (api) — тип `CommonEvent` (3 AgentEvent + 9 Conversation.Event вариантов).
|
||||||
|
- `kotlinx-coroutines-core` (api) — `Flow`.
|
||||||
|
|
||||||
|
Никаких `kotlinx-serialization`, `kotlin-logging`, platform-specific
|
||||||
|
зависимостей — этот модуль намеренно minimal.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-интерфейс bounded-tail event log'а. Implementation-specific TTL/cap
|
||||||
|
// eviction — caller's responsibility НЕ вызывать cleanup() (метод не существует).
|
||||||
|
//
|
||||||
|
// Тип [AllEvent] из :proto — typed envelope (3 AgentEvent + 9 Conversation.Event
|
||||||
|
// вариантов). :event-store отвечает за bounded-tail с auto-TTL, но не за
|
||||||
|
// сериализацию envelope'а — это делает :proto (уже @Serializable).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":proto"))
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ kotlinx-io = "0.8.0"
|
|||||||
ktor = "3.1.3"
|
ktor = "3.1.3"
|
||||||
a2a = "1.0.0-SNAPSHOT"
|
a2a = "1.0.0-SNAPSHOT"
|
||||||
kaml = "0.104.0"
|
kaml = "0.104.0"
|
||||||
litert = "8"
|
litert = "13"
|
||||||
sqldelight = "2.3.2"
|
sqldelight = "2.3.2"
|
||||||
shadow = "8.3.5"
|
shadow = "8.3.5"
|
||||||
jvector = "3.0.6"
|
jvector = "3.0.6"
|
||||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
text-embedding-kmp = "5"
|
||||||
kotlin-logging = "3.0.5"
|
kotlin-logging = "3.0.5"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
mosaic = "0.18.0"
|
mosaic = "0.18.0"
|
||||||
@@ -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-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
|
||||||
litert-openai = { module = "pw.binom.litert:litert-openai", 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" }
|
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 (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
|
||||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
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" }
|
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||||
|
|
||||||
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||||
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
|
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
|
||||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
|
# (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
|
||||||
|
# Версия 5 — первый релиз с реальными нативными klib-вариантами в caffeine
|
||||||
|
# (v4 имел только jvm+android, что ломало native-resolve в :memory-md-vector).
|
||||||
|
# Используется из :memory-md-vector и :memory-vector напрямую через
|
||||||
|
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
|
||||||
|
# нужный variant под target).
|
||||||
|
# `siglip` — JVM+Android only (onnx-runtime), подключается в jvmMain.
|
||||||
|
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "text-embedding-kmp" }
|
||||||
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
||||||
|
|
||||||
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||||
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" }
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
// Зеркалит набор :storage-core — in-memory импл, чтобы тесты и embedded
|
|
||||||
// (Android) запуски не зависели от SQLite/JDBC. Совпадает по семантике
|
|
||||||
// с :storage-sqlite (тред-безопасность через Mutex, AutoCloseable).
|
|
||||||
jvm()
|
jvm()
|
||||||
macosX64()
|
macosX64()
|
||||||
macosArm64()
|
macosArm64()
|
||||||
@@ -20,7 +17,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":storage-core"))
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
+4
-9
@@ -1,17 +1,12 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Часть контента сообщения на уровне хранилища.
|
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
|
||||||
*
|
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
|
||||||
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
|
* в `Mapping.kt` storage impl'ов.
|
||||||
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
|
|
||||||
* идентичны, но даёт возможность заменить transport-протокол без миграции
|
|
||||||
* таблиц.
|
|
||||||
*
|
|
||||||
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface Content {
|
sealed interface Content {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@@ -11,5 +11,4 @@ import kotlin.uuid.Uuid
|
|||||||
*/
|
*/
|
||||||
object Ids {
|
object Ids {
|
||||||
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
|
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
|
||||||
fun reflection(): String = new("refl")
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log сообщений — read-only представление.
|
||||||
|
*
|
||||||
|
* Producer-операция [MutableJournalStore.append] находится на
|
||||||
|
* [MutableJournalStore] — этот интерфейс только для чтения, чтобы
|
||||||
|
* consumer'ы физически не могли писать в audit log.
|
||||||
|
*
|
||||||
|
* Никаких обновлений, никакого удаления (кроме каскадного вместе
|
||||||
|
* с ConversationStore.delete).
|
||||||
|
*/
|
||||||
|
interface JournalStore : AutoCloseable {
|
||||||
|
|
||||||
|
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
||||||
|
* (по странице через `list()` пока не получит короткую страницу). Для
|
||||||
|
* in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть
|
||||||
|
* на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip.
|
||||||
|
*/
|
||||||
|
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
|
||||||
|
var offset = 0
|
||||||
|
while (true) {
|
||||||
|
val page = list(conversationId, after, offset, pageSize)
|
||||||
|
if (page.isEmpty()) return@flow
|
||||||
|
for (rec in page) emit(rec)
|
||||||
|
if (page.size < pageSize) return@flow
|
||||||
|
offset += page.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PAGE_SIZE = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageContext(
|
||||||
|
val origin: MessageOrigin,
|
||||||
|
val sourceId: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val metadata: JsonElement? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контекст инициации хода (кто/что и почему). Дубликат типа из `:proto` —
|
||||||
|
* живёт здесь чтобы не тащить `:proto` в слой хранения данных.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class MessageOrigin {
|
||||||
|
@SerialName("user")
|
||||||
|
USER,
|
||||||
|
|
||||||
|
@SerialName("system")
|
||||||
|
SYSTEM,
|
||||||
|
|
||||||
|
@SerialName("event")
|
||||||
|
EVENT,
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись в таблице `message` (append-only audit).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface MessageRecord {
|
||||||
|
val id: String
|
||||||
|
val conversationId: String
|
||||||
|
val createdAt: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface Body : MessageRecord {
|
||||||
|
val content: List<Content>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("user")
|
||||||
|
data class UserMessage(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
override val content: List<Content>,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
) : Body
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("assistant")
|
||||||
|
data class AssistantMessage(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
override val content: List<Content>,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
val tokens: TurnTokens? = null,
|
||||||
|
) : Body
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_call")
|
||||||
|
data class ToolCall(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val toolName: String,
|
||||||
|
val toolTitle: String?,
|
||||||
|
val toolArgsJson: String,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_result")
|
||||||
|
data class ToolResult(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val toolCallId: String,
|
||||||
|
/**
|
||||||
|
* Имя тула, денормализованное из соответствующего `MessageRecord.ToolCall.toolName`.
|
||||||
|
* Денормализация экономна (одна строка в SQLite) и снимает с UI
|
||||||
|
* необходимость сопоставления `toolCallId → toolName`. `null` —
|
||||||
|
* безопасный backfill для записей до миграции или для сиротливых
|
||||||
|
* результатов без предшествующего `ToolCall`.
|
||||||
|
*/
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String?,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("error")
|
||||||
|
data class Error(
|
||||||
|
override val id: String,
|
||||||
|
override val conversationId: String,
|
||||||
|
val message: String,
|
||||||
|
val code: String?,
|
||||||
|
override val createdAt: Instant,
|
||||||
|
) : MessageRecord
|
||||||
|
}
|
||||||
+2
-8
@@ -1,24 +1,18 @@
|
|||||||
package pw.binom.agentik.storage
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CRUD по таблице `conversation`.
|
* CRUD по таблице `conversation`.
|
||||||
*/
|
*/
|
||||||
interface ConversationStore : AutoCloseable {
|
interface MutableConversationStore : ConversationStore {
|
||||||
|
|
||||||
/** Создать или обновить snapshot диалога. */
|
/** Создать или обновить snapshot диалога. */
|
||||||
suspend fun upsert(record: ConversationRecord)
|
suspend fun upsert(record: ConversationRecord)
|
||||||
|
|
||||||
/** Диалог по id, или `null`. */
|
|
||||||
suspend fun get(id: String): ConversationRecord?
|
|
||||||
|
|
||||||
/** Удалить диалог (вместе с его сообщениями и working memory). */
|
/** Удалить диалог (вместе с его сообщениями и working memory). */
|
||||||
suspend fun delete(id: String): Boolean
|
suspend fun delete(id: String): Boolean
|
||||||
|
|
||||||
/** Список диалогов, отсортированный по `updatedAt` DESC. */
|
|
||||||
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
|
|
||||||
|
|
||||||
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
|
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
|
||||||
suspend fun rename(id: String, title: String?): Instant?
|
suspend fun rename(id: String, title: String?): Instant?
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable вариант [JournalStore] — добавляет producer-операцию [append].
|
||||||
|
*
|
||||||
|
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
|
||||||
|
* ConversationLoop, ToolDispatcher, sub-agents, A2A-bridge).
|
||||||
|
* Consumer'ы (DebugRoutes, admin dashboards, parent agents) должны
|
||||||
|
* принимать **read-only** [JournalStore] — тогда невозможно случайно
|
||||||
|
* записать в audit log из observer'а.
|
||||||
|
*
|
||||||
|
* **Append семантика**: см. KDoc [MessageStore.append][JournalStore] —
|
||||||
|
* на этом интерфейсе (не дублируем).
|
||||||
|
*/
|
||||||
|
interface MutableJournalStore : JournalStore {
|
||||||
|
suspend fun append(record: MessageRecord)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить все сообщения диалога [conversationId]. Используется
|
||||||
|
* владельцем lifecycle диалога при его удалении (каскад из
|
||||||
|
* ChatAgent.deleteConversation). Append-only природа audit log'а
|
||||||
|
* не нарушается — это bulk-clear, а не редактирование.
|
||||||
|
*/
|
||||||
|
suspend fun clear(conversationId: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private val bodyJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessageBodyPayload(
|
||||||
|
val content: List<Content>,
|
||||||
|
@SerialName("context")
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
val tokens: TurnTokens? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun encodeBodyPayload(
|
||||||
|
content: List<Content>,
|
||||||
|
context: MessageContext? = null,
|
||||||
|
tokens: TurnTokens? = null,
|
||||||
|
): String = bodyJson.encodeToString(
|
||||||
|
MessageBodyPayload.serializer(),
|
||||||
|
MessageBodyPayload(content = content, context = context, tokens = tokens),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
|
||||||
|
|
||||||
|
data class BodyDecoded(
|
||||||
|
val content: List<Content>,
|
||||||
|
val context: MessageContext?,
|
||||||
|
val tokens: TurnTokens? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun readPayload(json: String): BodyDecoded {
|
||||||
|
return try {
|
||||||
|
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
|
||||||
|
BodyDecoded(p.content, p.context, p.tokens)
|
||||||
|
} catch (e: kotlinx.serialization.SerializationException) {
|
||||||
|
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
|
||||||
|
BodyDecoded(arr, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token usage одного assistant turn'а.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TurnTokens(
|
||||||
|
val input: Int,
|
||||||
|
val output: Int,
|
||||||
|
) {
|
||||||
|
val total: Int get() = input + output
|
||||||
|
init {
|
||||||
|
require(input >= 0) { "input tokens must be non-negative, got $input" }
|
||||||
|
require(output >= 0) { "output tokens must be non-negative, got $output" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация [MutableJournalStore] и [MutableConversationStore] на
|
||||||
|
// `MutableList`/`MutableMap` + `Mutex` — для тестов, dev-режима,
|
||||||
|
// embedded-сценариев (Android core, CLI, in-process кэш в клиенте) и как
|
||||||
|
// образец для своей реализации.
|
||||||
|
//
|
||||||
|
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":journal-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package pw.binom.agentik.journal.inmemory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.journal.JournalStore
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простая in-memory [MutableJournalStore] для тестов, dev-режима и
|
||||||
|
* клиентских in-process кэшей.
|
||||||
|
*
|
||||||
|
* **Thread-safety**: `Mutex` поверх `MutableList<MessageRecord>`. Для
|
||||||
|
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
|
||||||
|
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
|
||||||
|
*
|
||||||
|
* **Контракт `list`**: возвращает подмножество с
|
||||||
|
* `conversationId == conversationId && createdAt > after`, отсортированное
|
||||||
|
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка.
|
||||||
|
*
|
||||||
|
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
|
||||||
|
* на сервере). [close] — no-op.
|
||||||
|
*
|
||||||
|
* Типичный кэш-паттерн в клиенте:
|
||||||
|
* ```
|
||||||
|
* val local = InMemoryJournalStore()
|
||||||
|
* val remote = HttpJournalStore(httpClient, baseUrl)
|
||||||
|
* // backfill + кэширование:
|
||||||
|
* remote.listFlow(convId, Instant.DISTANT_PAST).collect { local.append(it) }
|
||||||
|
* // после этого `local.list(convId, after, offset, limit)` отдаёт из кэша.
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class InMemoryJournalStore : MutableJournalStore {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val records: MutableList<MessageRecord> = mutableListOf()
|
||||||
|
|
||||||
|
override suspend fun append(record: MessageRecord): Unit = mutex.withLock {
|
||||||
|
records.add(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> = mutex.withLock {
|
||||||
|
records.asSequence()
|
||||||
|
.filter { it.conversationId == conversationId && it.createdAt > after }
|
||||||
|
.sortedBy { it.createdAt }
|
||||||
|
.drop(offset)
|
||||||
|
.take(limit)
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
|
||||||
|
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
|
||||||
|
records.removeAll { it.conversationId == conversationId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
|
||||||
|
suspend fun size(): Int = mutex.withLock { records.size }
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// no-op: lifecycle HttpClient'а — снаружи.
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import pw.binom.agentik.storage.ConversationRecord
|
|
||||||
import pw.binom.agentik.storage.ConversationStore
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread-safe Map-импл [ConversationStore].
|
* Thread-safe Map-импл [MutableConversationStore] для клиентских
|
||||||
|
* in-process кэшей (и тестов/dev-режима).
|
||||||
*
|
*
|
||||||
* Использует `Mutex` для атомарности read-modify-write операций
|
* Использует `Mutex` для атомарности read-modify-write операций
|
||||||
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления
|
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления
|
||||||
* (lost-update race), что в SQLite невозможно из-за driver-level locking.
|
* (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,
|
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()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
override suspend fun upsert(record: ConversationRecord) {
|
override suspend fun upsert(record: ConversationRecord) {
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package pw.binom.agentik.journal.inmemory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class InMemoryJournalStoreTest {
|
||||||
|
|
||||||
|
private fun userMsg(id: String, convId: String, text: String, at: Instant) =
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = id,
|
||||||
|
conversationId = convId,
|
||||||
|
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
|
||||||
|
createdAt = at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `append then list returns records sorted by createdAt ASC`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "first", t0))
|
||||||
|
store.append(userMsg("m2", "c1", "second", t0 + 1.seconds))
|
||||||
|
store.append(userMsg("m3", "c1", "third", t0 + 2.seconds))
|
||||||
|
|
||||||
|
val all = store.list("c1", Instant.DISTANT_PAST, 0, 100)
|
||||||
|
assertEquals(3, all.size)
|
||||||
|
assertEquals(listOf("m1", "m2", "m3"), all.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list filters by conversationId`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "a", t0))
|
||||||
|
store.append(userMsg("m2", "c2", "b", t0 + 1.seconds))
|
||||||
|
store.append(userMsg("m3", "c1", "c", t0 + 2.seconds))
|
||||||
|
|
||||||
|
assertEquals(2, store.list("c1", Instant.DISTANT_PAST, 0, 100).size)
|
||||||
|
assertEquals(1, store.list("c2", Instant.DISTANT_PAST, 0, 100).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list filters by after cursor`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
store.append(userMsg("m1", "c1", "a", t0))
|
||||||
|
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
|
||||||
|
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
|
||||||
|
|
||||||
|
val afterT0 = store.list("c1", t0, 0, 100)
|
||||||
|
assertEquals(listOf("m2", "m3"), afterT0.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list applies offset and limit`() = runTest {
|
||||||
|
val store = InMemoryJournalStore()
|
||||||
|
val t0 = Instant.parse("2026-09-21T10:00:00Z")
|
||||||
|
repeat(10) { i -> store.append(userMsg("m$i", "c1", "x", t0 + i.seconds)) }
|
||||||
|
|
||||||
|
val page = store.list("c1", Instant.DISTANT_PAST, offset = 3, limit = 4)
|
||||||
|
assertEquals(listOf("m3", "m4", "m5", "m6"), page.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear empties 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())
|
||||||
|
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
|
||||||
|
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-12
@@ -1,6 +1,6 @@
|
|||||||
package pw.binom.agentik.storage.inmemory
|
package pw.binom.agentik.journal.inmemory
|
||||||
|
|
||||||
import pw.binom.agentik.storage.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
@@ -9,11 +9,11 @@ import kotlin.test.assertTrue
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
||||||
class InMemoryConversationStoreTest {
|
class InMemoryMutableConversationStoreTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `upsert and get roundtrip preserves all fields`() = runTest {
|
fun `upsert and get roundtrip preserves all fields`() = runTest {
|
||||||
val store = InMemoryConversationStore()
|
val store = InMemoryMutableConversationStore()
|
||||||
val rec = ConversationRecord(
|
val rec = ConversationRecord(
|
||||||
id = "c1",
|
id = "c1",
|
||||||
title = "test",
|
title = "test",
|
||||||
@@ -28,13 +28,13 @@ class InMemoryConversationStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `get returns null for missing id`() = runTest {
|
fun `get returns null for missing id`() = runTest {
|
||||||
val store = InMemoryConversationStore()
|
val store = InMemoryMutableConversationStore()
|
||||||
assertNull(store.get("nope"))
|
assertNull(store.get("nope"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `delete removes the record and returns true`() = runTest {
|
fun `delete removes the record and returns true`() = runTest {
|
||||||
val store = InMemoryConversationStore()
|
val store = InMemoryMutableConversationStore()
|
||||||
store.upsert(
|
store.upsert(
|
||||||
ConversationRecord(
|
ConversationRecord(
|
||||||
"c1", null, false,
|
"c1", null, false,
|
||||||
@@ -50,7 +50,7 @@ class InMemoryConversationStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest {
|
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")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||||
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
|
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
|
||||||
@@ -69,7 +69,7 @@ class InMemoryConversationStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
|
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")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
store.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class InMemoryConversationStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rename with null title clears it`() = runTest {
|
fun `rename with null title clears it`() = runTest {
|
||||||
val store = InMemoryConversationStore()
|
val store = InMemoryMutableConversationStore()
|
||||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
store.upsert(ConversationRecord("c1", "old", false, t0, t0))
|
store.upsert(ConversationRecord("c1", "old", false, t0, t0))
|
||||||
store.rename("c1", null)
|
store.rename("c1", null)
|
||||||
@@ -93,13 +93,13 @@ class InMemoryConversationStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rename returns null for missing conversation`() = runTest {
|
fun `rename returns null for missing conversation`() = runTest {
|
||||||
val store = InMemoryConversationStore()
|
val store = InMemoryMutableConversationStore()
|
||||||
assertNull(store.rename("nope", "x"))
|
assertNull(store.rename("nope", "x"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `touch bumps updatedAt without changing other fields`() = runTest {
|
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 t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||||
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
|
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
|
||||||
@@ -112,7 +112,7 @@ class InMemoryConversationStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `close is idempotent and does nothing`() {
|
fun `close is idempotent and does nothing`() {
|
||||||
val store = InMemoryConversationStore()
|
val store = InMemoryMutableConversationStore()
|
||||||
store.close()
|
store.close()
|
||||||
store.close() // должно быть no-op
|
store.close() // должно быть no-op
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KMP-реализация :journal-api (JournalStore / MutableJournalStore) поверх ksqlite.
|
||||||
|
// Минимальная — только таблица `message` для append-only audit log'а.
|
||||||
|
// ConversationStore / ReflectionStore / WorkingMemoryStore живут в своих
|
||||||
|
// собственных ksqlite-модулях.
|
||||||
|
//
|
||||||
|
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
|
||||||
|
// на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
api(project(":journal-api"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
|
||||||
|
*
|
||||||
|
* Единственный класс для 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] ДО закрытия 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] рядом.
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
|
||||||
|
private val insertStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_MESSAGE}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val listStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
|
||||||
|
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
|
||||||
|
FROM ${Schema.TABLE_MESSAGE}
|
||||||
|
WHERE ${Schema.COL_CONVERSATION_ID} = ?
|
||||||
|
AND ${Schema.COL_CREATED_AT} > ?
|
||||||
|
ORDER BY ${Schema.COL_CREATED_AT} ASC, ${Schema.COL_ID} ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val clearStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
|
||||||
|
val (kind, payload) = encodeRecord(record)
|
||||||
|
mutex.withLock {
|
||||||
|
insertStmt.reset()
|
||||||
|
insertStmt.clearBindings()
|
||||||
|
insertStmt.bindText(1, record.id)
|
||||||
|
insertStmt.bindText(2, record.conversationId)
|
||||||
|
insertStmt.bindText(3, kind)
|
||||||
|
insertStmt.bindText(4, payload)
|
||||||
|
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
|
||||||
|
insertStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
conversationId: String,
|
||||||
|
after: Instant,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int,
|
||||||
|
): List<MessageRecord> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listStmt.reset()
|
||||||
|
listStmt.clearBindings()
|
||||||
|
listStmt.bindText(1, conversationId)
|
||||||
|
listStmt.bindLong(2, after.toEpochMilliseconds())
|
||||||
|
listStmt.bindLong(3, limit.toLong())
|
||||||
|
listStmt.bindLong(4, offset.toLong())
|
||||||
|
val out = mutableListOf<MessageRecord>()
|
||||||
|
listStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
out.add(rs.toMessageRecord(json))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
clearStmt.reset()
|
||||||
|
clearStmt.clearBindings()
|
||||||
|
clearStmt.bindText(1, conversationId)
|
||||||
|
clearStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
insertStmt.close()
|
||||||
|
listStmt.close()
|
||||||
|
clearStmt.close()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
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.MutableConversationStore
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 KsqliteMutableConversationStore private constructor(
|
||||||
|
private val connection: SQLiteConnection,
|
||||||
|
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()
|
||||||
|
|
||||||
|
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
|
||||||
|
// KDoc там — почему GC-finalize на StmtHolder'е роняет JVM, если stmt
|
||||||
|
// живёт после закрытия connection).
|
||||||
|
private val existsStmt = connection.prepare(
|
||||||
|
"SELECT 1 FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
|
||||||
|
)
|
||||||
|
private val updateStmt = connection.prepare(
|
||||||
|
"""
|
||||||
|
UPDATE ${Schema.TABLE_CONVERSATION}
|
||||||
|
SET ${Schema.COL_TITLE} = ?, ${Schema.COL_IS_TEMPORAL} = ?, ${Schema.COL_UPDATED_AT} = ?
|
||||||
|
WHERE ${Schema.COL_ID} = ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val insertStmt = connection.prepare(
|
||||||
|
"""
|
||||||
|
INSERT INTO ${Schema.TABLE_CONVERSATION}
|
||||||
|
(${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
|
||||||
|
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val getStmt = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT ${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
|
||||||
|
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT}
|
||||||
|
FROM ${Schema.TABLE_CONVERSATION}
|
||||||
|
WHERE ${Schema.COL_ID} = ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val deleteStmt = connection.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
|
||||||
|
)
|
||||||
|
private val listStmt = connection.prepare(
|
||||||
|
"""
|
||||||
|
SELECT ${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
|
||||||
|
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT}
|
||||||
|
FROM ${Schema.TABLE_CONVERSATION}
|
||||||
|
WHERE ${Schema.COL_IS_TEMPORAL} = 0
|
||||||
|
ORDER BY ${Schema.COL_UPDATED_AT} DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val renameStmt = connection.prepare(
|
||||||
|
"""
|
||||||
|
UPDATE ${Schema.TABLE_CONVERSATION}
|
||||||
|
SET ${Schema.COL_TITLE} = ?, ${Schema.COL_UPDATED_AT} = ?
|
||||||
|
WHERE ${Schema.COL_ID} = ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
private val renameUpdatedAtStmt = connection.prepare(
|
||||||
|
"SELECT ${Schema.COL_UPDATED_AT} FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
|
||||||
|
)
|
||||||
|
private val touchStmt = connection.prepare(
|
||||||
|
"""
|
||||||
|
UPDATE ${Schema.TABLE_CONVERSATION}
|
||||||
|
SET ${Schema.COL_UPDATED_AT} = ?
|
||||||
|
WHERE ${Schema.COL_ID} = ?
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun upsert(record: ConversationRecord): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
val exists = execExists(record.id)
|
||||||
|
if (exists) {
|
||||||
|
updateStmt.reset()
|
||||||
|
updateStmt.clearBindings()
|
||||||
|
val t = record.title
|
||||||
|
if (t != null) updateStmt.bindText(1, t) else updateStmt.bindNull(1)
|
||||||
|
updateStmt.bindInt(2, if (record.isTemporal) 1 else 0)
|
||||||
|
updateStmt.bindLong(3, record.updatedAt.toEpochMilliseconds())
|
||||||
|
updateStmt.bindText(4, record.id)
|
||||||
|
updateStmt.executeUpdate()
|
||||||
|
} else {
|
||||||
|
insertStmt.reset()
|
||||||
|
insertStmt.clearBindings()
|
||||||
|
insertStmt.bindText(1, record.id)
|
||||||
|
val title = record.title
|
||||||
|
if (title != null) insertStmt.bindText(2, title) else insertStmt.bindNull(2)
|
||||||
|
insertStmt.bindInt(3, if (record.isTemporal) 1 else 0)
|
||||||
|
insertStmt.bindLong(4, record.createdAt.toEpochMilliseconds())
|
||||||
|
insertStmt.bindLong(5, record.updatedAt.toEpochMilliseconds())
|
||||||
|
insertStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(id: String): ConversationRecord? = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
getStmt.reset()
|
||||||
|
getStmt.clearBindings()
|
||||||
|
getStmt.bindText(1, id)
|
||||||
|
getStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) rs.toRecord() else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
// Проверяем существование через raw query, НЕ через get() — get() тоже
|
||||||
|
// берёт mutex (не реентрант), что привело бы к deadlock.
|
||||||
|
if (!execExists(id)) return@withContext false
|
||||||
|
deleteStmt.reset()
|
||||||
|
deleteStmt.clearBindings()
|
||||||
|
deleteStmt.bindText(1, id)
|
||||||
|
deleteStmt.executeUpdate()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
listStmt.reset()
|
||||||
|
listStmt.clearBindings()
|
||||||
|
listStmt.bindLong(1, limit.toLong())
|
||||||
|
listStmt.bindLong(2, offset.toLong())
|
||||||
|
val result = mutableListOf<ConversationRecord>()
|
||||||
|
listStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) result.add(rs.toRecord())
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rename(id: String, title: String?): Instant? = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
|
renameStmt.reset()
|
||||||
|
renameStmt.clearBindings()
|
||||||
|
if (title != null) renameStmt.bindText(1, title) else renameStmt.bindNull(1)
|
||||||
|
renameStmt.bindLong(2, nowMs)
|
||||||
|
renameStmt.bindText(3, id)
|
||||||
|
renameStmt.executeUpdate()
|
||||||
|
|
||||||
|
renameUpdatedAtStmt.reset()
|
||||||
|
renameUpdatedAtStmt.clearBindings()
|
||||||
|
renameUpdatedAtStmt.bindText(1, id)
|
||||||
|
renameUpdatedAtStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) Instant.fromEpochMilliseconds(rs.getLong(0)!!) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun touch(id: String, now: Instant): Unit = withContext(Dispatchers.Default) {
|
||||||
|
mutex.withLock {
|
||||||
|
touchStmt.reset()
|
||||||
|
touchStmt.clearBindings()
|
||||||
|
touchStmt.bindLong(1, now.toEpochMilliseconds())
|
||||||
|
touchStmt.bindText(2, id)
|
||||||
|
touchStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
existsStmt.close()
|
||||||
|
updateStmt.close()
|
||||||
|
insertStmt.close()
|
||||||
|
getStmt.close()
|
||||||
|
deleteStmt.close()
|
||||||
|
listStmt.close()
|
||||||
|
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 {
|
||||||
|
existsStmt.reset()
|
||||||
|
existsStmt.clearBindings()
|
||||||
|
existsStmt.bindText(1, id)
|
||||||
|
existsStmt.executeQuery().use { rs -> return rs.next() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pw.binom.db.ksqlite.SQLiteResultSet.toRecord(): ConversationRecord = ConversationRecord(
|
||||||
|
id = getText(0)!!,
|
||||||
|
title = getText(1),
|
||||||
|
isTemporal = (getInt(2) ?: 0) != 0,
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(getLong(3)!!),
|
||||||
|
updatedAt = Instant.fromEpochMilliseconds(getLong(4)!!),
|
||||||
|
)
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.decodeBodyPayload
|
||||||
|
import pw.binom.agentik.journal.encodeBodyPayload
|
||||||
|
import pw.binom.db.ksqlite.SQLiteResultSet
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
|
||||||
|
*
|
||||||
|
* `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
|
||||||
|
* не в каком-то внешнем общем модуле.
|
||||||
|
*/
|
||||||
|
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
|
||||||
|
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
|
||||||
|
content = record.content,
|
||||||
|
context = record.context,
|
||||||
|
)
|
||||||
|
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
|
||||||
|
content = record.content,
|
||||||
|
tokens = record.tokens,
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
|
||||||
|
CallPayload.serializer(),
|
||||||
|
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
|
||||||
|
ResultPayload.serializer(),
|
||||||
|
ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
|
||||||
|
)
|
||||||
|
is MessageRecord.Error -> "error" to Json.encodeToString(
|
||||||
|
ErrorPayload.serializer(),
|
||||||
|
ErrorPayload(message = record.message, code = record.code),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||||
|
val id = getText(0)!!
|
||||||
|
val convId = getText(1)!!
|
||||||
|
val kind = getText(2)!!
|
||||||
|
val payload = getText(3)!!
|
||||||
|
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
|
||||||
|
return when (kind) {
|
||||||
|
"user" -> {
|
||||||
|
val d = decodeBodyPayload(payload)
|
||||||
|
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
|
||||||
|
}
|
||||||
|
"assistant" -> {
|
||||||
|
val d = decodeBodyPayload(payload)
|
||||||
|
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
|
||||||
|
}
|
||||||
|
"tool_call" -> {
|
||||||
|
val p = Json.decodeFromString(CallPayload.serializer(), payload)
|
||||||
|
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
|
||||||
|
}
|
||||||
|
"tool_result" -> {
|
||||||
|
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
|
||||||
|
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||||
|
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
|
||||||
|
}
|
||||||
|
else -> error("Unknown message kind in audit log: $kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
|
||||||
|
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
|
||||||
|
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
|
||||||
|
* без поля десериализуются как `null`.
|
||||||
|
*/
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class ResultPayload(
|
||||||
|
val toolCallId: String,
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
internal data class ErrorPayload(val message: String, val code: String?)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
|
||||||
|
*
|
||||||
|
* Владеет двумя таблицами:
|
||||||
|
* - `conversation` — реестр диалогов агента (см. ConversationRecord);
|
||||||
|
* - `message` — append-only audit log сообщений диалогов.
|
||||||
|
*
|
||||||
|
* `working_memory` и `reflection` живут в других ksqlite-модулях.
|
||||||
|
*
|
||||||
|
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
|
||||||
|
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
|
||||||
|
*/
|
||||||
|
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 IDX_CONV_UPDATED = "idx_conv_updated"
|
||||||
|
const val IDX_MSG_CONV = "idx_msg_conv"
|
||||||
|
|
||||||
|
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,
|
||||||
|
$COL_KIND TEXT NOT NULL,
|
||||||
|
$COL_PAYLOAD_JSON TEXT NOT NULL,
|
||||||
|
$COL_CREATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
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);
|
||||||
|
"""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
|
||||||
|
*
|
||||||
|
* Гарантии:
|
||||||
|
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
|
||||||
|
* уже-мигрированной БД;
|
||||||
|
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
|
||||||
|
* ROLLBACK оставит БД консистентной.
|
||||||
|
*
|
||||||
|
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
|
||||||
|
* user_version как gate перестал работать — два модуля ставят его в 1,
|
||||||
|
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
|
||||||
|
* idempotently; координация multi-module миграций — ответственность
|
||||||
|
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
|
||||||
|
*/
|
||||||
|
fun migrate(conn: SQLiteConnection) {
|
||||||
|
conn.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
conn.exec(v1ConversationDdl)
|
||||||
|
conn.exec(v1MessageDdl)
|
||||||
|
conn.exec(v1IndexesDdl)
|
||||||
|
conn.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package pw.binom.agentik.journal.ksqlite
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.TurnTokens
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
|
||||||
|
* SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
|
||||||
|
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
|
||||||
|
*/
|
||||||
|
class KsqliteJournalStoreTest {
|
||||||
|
|
||||||
|
private lateinit var conn: SQLiteConnection
|
||||||
|
private lateinit var store: KsqliteJournalStore
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
|
||||||
|
store = KsqliteJournalStore(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
store.close()
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendUserAndRetrieve() = runTest {
|
||||||
|
store.append(MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "conv1",
|
||||||
|
content = listOf(Content.Text("hello")),
|
||||||
|
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||||
|
context = null,
|
||||||
|
))
|
||||||
|
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
val msg = list[0]
|
||||||
|
assertEquals("m1", msg.id)
|
||||||
|
assertEquals(MessageRecord.UserMessage::class, msg::class)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppendAssistantWithTokens() = runTest {
|
||||||
|
store.append(MessageRecord.AssistantMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "conv1",
|
||||||
|
content = listOf(Content.Text("hi")),
|
||||||
|
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
|
||||||
|
tokens = TurnTokens(input = 50, output = 30),
|
||||||
|
))
|
||||||
|
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
val msg = list[0] as MessageRecord.AssistantMessage
|
||||||
|
assertEquals(TurnTokens(input = 50, output = 30), msg.tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListAfterFiltersByTimestamp() = runTest {
|
||||||
|
val t1 = Instant.parse("2026-09-15T10:01:00Z")
|
||||||
|
val t2 = Instant.parse("2026-09-15T10:02:00Z")
|
||||||
|
val t3 = Instant.parse("2026-09-15T10:03:00Z")
|
||||||
|
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
|
||||||
|
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
|
||||||
|
|
||||||
|
val after = store.list("conv1", after = t1, offset = 0, limit = 10)
|
||||||
|
assertEquals(2, after.size)
|
||||||
|
assertEquals(listOf("m2", "m3"), after.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListFlowReturnsAllInOrder() = runTest {
|
||||||
|
val t = Instant.parse("2026-09-15T10:00:00Z")
|
||||||
|
for (i in 1..3) store.append(
|
||||||
|
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
|
||||||
|
)
|
||||||
|
assertEquals(listOf("m1", "m2", "m3"), store.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testClearRemovesByConversation() = runTest {
|
||||||
|
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), Instant.parse("2026-09-15T10:00:00Z"), null))
|
||||||
|
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), Instant.parse("2026-09-15T10:00:00Z"), null))
|
||||||
|
store.clear("conv1")
|
||||||
|
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
|
||||||
|
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
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() в `: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.
|
||||||
|
*
|
||||||
|
* `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
|
||||||
|
* в `pw.binom.agentik.reflection.ksqlite`.
|
||||||
|
*/
|
||||||
|
class SchemaMigrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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)
|
||||||
|
|
||||||
|
for (table in listOf(
|
||||||
|
Schema.TABLE_CONVERSATION,
|
||||||
|
Schema.TABLE_MESSAGE,
|
||||||
|
)) {
|
||||||
|
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index in listOf(
|
||||||
|
Schema.IDX_CONV_UPDATED,
|
||||||
|
Schema.IDX_MSG_CONV,
|
||||||
|
)) {
|
||||||
|
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migrate is idempotent on already-migrated DB`() = runTest {
|
||||||
|
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
|
||||||
|
try {
|
||||||
|
Schema.migrate(conn)
|
||||||
|
// повторный вызов не должен ни упасть, ни пересоздать таблицы
|
||||||
|
// (CREATE IF NOT EXISTS — no-op)
|
||||||
|
Schema.migrate(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 stores`() = runTest {
|
||||||
|
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
|
||||||
|
Schema.migrate(conn)
|
||||||
|
|
||||||
|
val convStore = KsqliteMutableConversationStore(conn)
|
||||||
|
val msgStore = KsqliteJournalStore(conn)
|
||||||
|
try {
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
msgStore.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m1", conversationId = "c1",
|
||||||
|
content = listOf(Content.Text("hi")),
|
||||||
|
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
|
||||||
|
assertEquals(1, got.size)
|
||||||
|
assertEquals("m1", got[0].id)
|
||||||
|
} finally {
|
||||||
|
convStore.close()
|
||||||
|
msgStore.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
|
||||||
|
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
|
||||||
|
try {
|
||||||
|
Schema.migrate(conn)
|
||||||
|
val cols = indexColumns(conn, Schema.IDX_MSG_CONV)
|
||||||
|
assertEquals(listOf(Schema.COL_CONVERSATION_ID, Schema.COL_CREATED_AT), cols)
|
||||||
|
} finally {
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableExists(conn: SQLiteConnection, name: String): Boolean {
|
||||||
|
conn.prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"
|
||||||
|
).use { stmt ->
|
||||||
|
stmt.bindText(1, name)
|
||||||
|
stmt.executeQuery().use { rs -> return rs.next() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun indexExists(conn: SQLiteConnection, name: String): Boolean {
|
||||||
|
conn.prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?"
|
||||||
|
).use { stmt ->
|
||||||
|
stmt.bindText(1, name)
|
||||||
|
stmt.executeQuery().use { rs -> return rs.next() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun indexColumns(conn: SQLiteConnection, indexName: String): List<String> {
|
||||||
|
// PRAGMA index_info возвращает одну строку на колонку индекса
|
||||||
|
// (seqno, cid, name). Параметризовать через `?` нельзя — собираем
|
||||||
|
// строку (name — контролируемая константа, не user input).
|
||||||
|
val cols = mutableListOf<String>()
|
||||||
|
conn.prepare("PRAGMA index_info($indexName)").use { stmt ->
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
rs.getText(2)?.let(cols::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
|
||||||
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
|
||||||
//
|
//
|
||||||
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
|
// KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
|
||||||
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
|
// `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
|
||||||
// доступные JVM target'у.
|
|
||||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
@@ -15,23 +14,35 @@ kotlin {
|
|||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
jvm()
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":memory-api"))
|
api(project(":memory-api"))
|
||||||
api(project(":storage-core"))
|
api(project(":journal-api"))
|
||||||
|
api(project(":reflection-api"))
|
||||||
|
api(project(":context-api"))
|
||||||
api(project(":skills"))
|
api(project(":skills"))
|
||||||
api(libs.litert.api)
|
api(libs.litert.api)
|
||||||
|
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
|
||||||
|
// jvm/native variant из общего артефакта).
|
||||||
|
implementation(libs.kotlin.logging)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
}
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
jvmMain.dependencies {
|
implementation(libs.litert.tools.kotlinx.serialization)
|
||||||
// mu.KotlinLogging — JVM-only, для SkillMiner'а
|
implementation(project(":memory-md"))
|
||||||
implementation(libs.kotlin.logging)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
|
|||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
import pw.binom.agentik.memory.NewMemoryNote
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
import pw.binom.agentik.storage.Ids
|
import pw.binom.agentik.journal.Ids
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import pw.binom.agentik.memory.ConversationTurn
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
import pw.binom.litert.LiteConversationConfig
|
import pw.binom.litert.LiteConversationConfig
|
||||||
import pw.binom.litert.LiteLlm
|
import pw.binom.litert.LiteLlm
|
||||||
import pw.binom.agentik.storage.Ids
|
import pw.binom.agentik.journal.Ids
|
||||||
import pw.binom.agentik.storage.Reflection
|
import pw.binom.agentik.reflection.Reflection
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +29,7 @@ class LlmReflector(
|
|||||||
private val llm: LiteLlm,
|
private val llm: LiteLlm,
|
||||||
val maxTurns: Int = 6,
|
val maxTurns: Int = 6,
|
||||||
private val maxTokens: Int = 512,
|
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,
|
private val clock: Clock = Clock.System,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,7 @@ class LlmReflector(
|
|||||||
val parsed = ReflectionParser.parse(raw)
|
val parsed = ReflectionParser.parse(raw)
|
||||||
?: return@withContext null
|
?: return@withContext null
|
||||||
Reflection(
|
Reflection(
|
||||||
id = Ids.reflection(),
|
id = pw.binom.agentik.reflection.Ids.new(),
|
||||||
conversationId = null, // будет проставлен caller'ом ChatConversation
|
conversationId = null, // будет проставлен caller'ом ChatConversation
|
||||||
createdAt = clock.now(),
|
createdAt = clock.now(),
|
||||||
turnsAnalyzed = turns.size,
|
turnsAnalyzed = turns.size,
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.llm.tools
|
||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent.memory
|
package pw.binom.agentik.llm.tools.memory
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -8,7 +8,7 @@ import pw.binom.agentik.memory.MemorySource
|
|||||||
import pw.binom.agentik.memory.MemoryStore
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
import pw.binom.agentik.standalone.agent.FakeLiteLlm
|
import pw.binom.agentik.llm.tools.FakeLiteLlm
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package pw.binom.agentik.standalone.agent.memory
|
package pw.binom.agentik.llm.tools.memory
|
||||||
|
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
@@ -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 когда те снова включатся.
|
// или :agentik-tui когда те снова включатся.
|
||||||
//
|
//
|
||||||
// Зависимости:
|
// Зависимости:
|
||||||
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
|
// - litert.api для LiteTool контракта (в v9 у LiteTool появилось поле name,
|
||||||
// - litert.api для LiteTool контракта
|
// NamedTool-обёртка из :agent-toolsets больше не нужна)
|
||||||
// - MCP SDK (JVM-only)
|
// - MCP SDK (JVM-only)
|
||||||
// - Ktor client (для StreamableHttpClientTransport)
|
// - Ktor client (для StreamableHttpClientTransport)
|
||||||
// - kotlinx-serialization для парсинга конфига
|
// - kotlinx-serialization для парсинга конфига
|
||||||
@@ -22,7 +22,9 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":agent-toolsets"))
|
// :agent-api — отсюда Component / ToolProvider; McpBridgeComponent
|
||||||
|
// реализует Component и подсовывает MCP-тулы через ToolProvider.
|
||||||
|
api(project(":agent-api"))
|
||||||
|
|
||||||
api(libs.litert.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 kotlinx.serialization.json.put
|
||||||
import pw.binom.litert.LiteTool
|
import pw.binom.litert.LiteTool
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import pw.binom.agentik.toolsets.NamedTool
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Реестр подключённых MCP-серверов.
|
* Реестр подключённых MCP-серверов.
|
||||||
@@ -60,11 +59,6 @@ class McpRegistry(
|
|||||||
connected.values.flatMap { it.tools }
|
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
|
val connectedServerCount: Int get() = connected.size
|
||||||
|
|
||||||
@@ -181,13 +175,13 @@ internal class McpLiteToolAdapter(
|
|||||||
private val client: Client,
|
private val client: Client,
|
||||||
) : LiteTool {
|
) : LiteTool {
|
||||||
|
|
||||||
internal val fullName: String = "${serverName}__${tool.name}"
|
override val name: String = "${serverName}__${tool.name}"
|
||||||
|
|
||||||
override fun describe(): String =
|
override fun describe(): String =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
|
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
|
||||||
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
|
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
|
||||||
put("name", fullName)
|
put("name", name)
|
||||||
put("description", tool.description ?: "")
|
put("description", tool.description ?: "")
|
||||||
put("parameters", tool.inputSchema.toJsonSchema())
|
put("parameters", tool.inputSchema.toJsonSchema())
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
// `TextEmbeddingExecutor` (suspend-обёртка над `TextEmbeddingExtractor`)
|
||||||
|
// живёт в :memory-api с 2026-09-21 — раньше был `EmbeddingProvider` в
|
||||||
|
// :memory-vector, но он JVM-only и блокировал :memory-md-vector от
|
||||||
|
// нативных таргетов. text-embedding-kmp:api собирается под jvm+android+
|
||||||
|
// linux/macos/ios/mingw (мы добавили нативные цели в их :api модуле),
|
||||||
|
// так что KMP-потребители могут зависеть от него напрямую.
|
||||||
|
api(libs.text.embedding.api)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
|||||||
@@ -24,4 +24,11 @@ data class MemoryNote(
|
|||||||
val useCount: Int = 0,
|
val useCount: Int = 0,
|
||||||
val conversationId: String? = null,
|
val conversationId: String? = null,
|
||||||
val source: MemorySource,
|
val source: MemorySource,
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* Дешёвый content-fingerprint: хэш от id + content.
|
||||||
|
* Используется vector-кэшами (`:memory-md-vector`, `:memory-vector`) для
|
||||||
|
* определения "изменилась ли заметка" без re-embed'а.
|
||||||
|
*/
|
||||||
|
fun contentHash(): String = (id.hashCode().toLong() xor content.hashCode().toLong()).toString(16)
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user