16 Commits
7 ... 13

Author SHA1 Message Date
subochev a81d92f489 Add count methods to JournalStore API and implementations for message counting per conversation (count(conversationId) and count(conversationId, after)), with supporting tests.
release / Publish KMP libraries → caffeine Nexus (release) Failing after 32s
2026-09-23 05:31:13 +03:00
subochev 3e583ac8ea Relocate CI workflow file from .gitea/workflows/ci.yml to .gitea/ci.yml and update README.md accordingly.
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m16s
2026-09-23 04:47:43 +03:00
subochev f878d1c79b Update deployment host IP in standalone/build.gradle.kts configuration
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:46:37 +03:00
subochev 266ec38c1b Refactor: replace BackgroundScheduler with ReflectionScheduler, migrate to outbox-driven event processing, and remove skill mining logic
ci / JVM build + tests (push) Has been cancelled
2026-09-23 04:40:45 +03:00
subochev e447525059 Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s
2026-09-23 03:48:38 +03:00
subochev 84f5fd84f3 remove :storage-ksqlite (conversation/message) and related tests; decouple schema from journal
ci / JVM build + tests (push) Successful in 6m6s
2026-09-22 16:01:05 +03:00
subochev 639c7d1748 docs(client): add «Кэш списка бесед» section + ios targets to :journal-inmemory
ci / JVM build + tests (push) Successful in 5m49s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 36s
- :journal-inmemory — added iosX64/iosArm64/iosSimulatorArm64 to the
  target set so :storage-inmemory (which now depends on it) can build
  for iOS. Pure `MutableMap`+`Mutex` impl, no I/O, fully portable.

- client/README.md — new «Кэш списка бесед» section:
  - shows `agent.conversationStore` as the read-only entry point;
  - demonstrates the «remote.listFlow → local.upsert + outbox.agentEvents
    → local apply» pattern (Created/Renamed/Touched/Deleted);
  - notes that the cache is built into `AgentikAgent` by default;
  - mentions `wrapWithLocalConversationCache` for custom stores (SQLite/JSON);
  - points to `agentikHttpClient(...).raw` as the escape-hatch for clients
    that want direct HTTP.

Also adds `HttpConversationStore.kt` to the index (the file existed on
disk but wasn't `git add`ed in the previous commit).
2026-09-22 05:25:39 +03:00
subochev 5f0e0da361 feat(client): client-side caching for conversation list via agent.outbox events
Adds `agent.conversationStore` (read-only view on `conversation` table) to
the :proto Agent interface, plus `agent.renameConversation(id, title?)`
command. Client-side cache in :client is built from a snapshot
(`remote.listFlow(0)` → `local.upsert(...)`) + live updates via
`outbox.agentEvents()` (Created/Deleted/Renamed/Touched).

Changes:
- :journal-api — split `ConversationStore` (read-only: get/list) and
  `MutableConversationStore` (CRUD: upsert/delete/rename/touch);
  `ConversationStore` gained `listFlow` (cold-flow paging via `list`).
- :outbox-api — `AgentEvent.Touched(date, id, updatedAt)` event so
  client cache stays fresh after `send()` (which bumps `updatedAt`).
- :proto.Agent — added `conversationStore: ConversationStore` property,
  added `renameConversation(id, title?): Instant?` command, removed
  `getConversations(offset, limit)` (now: `conversationStore.list(...)`).
- :server — `GET /conversations` now returns `List<ConversationRecord>`
  (lightweight metadata, no handle/image-support flags); `PATCH
  /conversations/{id}` uses `agent.renameConversation` and returns
  the updated `ConversationRecord`.
- :journal-inmemory — expanded targets to jvm+macos+linux+mingw (matches
  :client); moved `InMemoryMutableConversationStore` here from
  :storage-inmemory so :client can use it without pulling ios targets.
- :storage-inmemory — depends on :journal-inmemory.
- :storage-ksqlite — pre-staged rename `KsqliteConversationStore` →
  `KsqliteMutableConversationStore` to match the new interface split.
- :standalone — `ChatAgent` exposes `conversationStore` as a read-only
  view of its `mutableConversationStore`; emits `AgentEvent.Touched`
  after each `send()` (after `conversationStore.touch(id, ts)`).
- :client — new `HttpConversationStore` (read-only HTTP impl);
  `AgentikAgent` wraps the agent with `wrapWithLocalConversationCache`
  so the client sees an in-memory cache (snapshot + outbox events)
  instead of direct HTTP. Cache scope + HttpClient + background job
  all cancelled in `agent.close()`.
- :client/README — new «Кэш списка бесед» section with the
  `listFlow → upsert` / `agentEvents → apply` pattern and a note that
  `conversationStore` is read-only (writes only via Agent commands).

All 96 jvmTest tasks green.
2026-09-22 05:21:01 +03:00
subochev acb4ee6186 fix(client): make ReconnectingOutbox KMP-native-safe (@Volatile→AtomicReference, Math.pow→kotlin.math.pow)
ci / JVM build + tests (push) Successful in 5m48s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 35s
CI red on tag 10 release #1983: client:compileCommonMainKotlinMetadata
and :compileKotlinLinuxArm64 both failed with:
  e: ReconnectingOutbox.kt:154 Unresolved reference 'Volatile'
  e: ReconnectingOutbox.kt:227 Unresolved reference 'Math'

@Volatile is JVM-only annotation; java.lang.Math is JVM-only API. On
linuxArm64/macosArm64 they don't resolve.

Fix:
- @Volatile private var lastSeen: Instant? → AtomicReference<Instant?>
  (kotlin.concurrent.atomics, same module as the AtomicBoolean already
  used for ). .load() / .store() / @OptIn(ExperimentalAtomicApi::class).
- Math.pow(m, e) → m.pow(e) via kotlin.math.pow import.

commonMain stays KMP-clean; jvmTest green (95 tasks); linuxX64 / linuxArm64
/ mingwX64 / macosX64 / macosArm64 compile green.
2026-09-22 03:13:02 +03:00
subochev c0a933d251 feat(client): add ReconnectingOutbox with parallel connectionStatus flow
ci / JVM build + tests (push) Successful in 6m3s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 23s
Android-client review item 9: every client reimplements SSE reconnect
with cursor preservation, exponential backoff, and connection-status UI
signals. ReconnectingOutbox extracts that into the lib.

Design:
- wraps any OutboxStore (HttpEventStore or local InMemoryJournalStore)
- two INDEPENDENT parallel flows — never mixed:
  - events(after): Flow<CommonEvent> with auto-reconnect, cursor
    (lastSeen) preserved across retries, so client never loses events
  - connectionStatus(): Flow<ConnectionStatus> = Connecting(attempt) /
    Connected(since) / Disconnected(reason, willRetryIn) / Failed(cause)
    — for UI banner / spinner; NOT emitted into CommonEvent stream
- BackoffPolicy.Default: initial=1s, max=30s, multiplier=2.0,
  jitter=0.2 (±20% spread), maxAttempts=∞
- BackoffPolicy.Fixed(delay, attempts) for tests
- After maxAttempts exhaustion: Failed + flow closes
- recon.close() cancels background job, both flows terminate

Tests (4 cases, all green):
- first event → Connecting(1) + Connected + event delivered
- disconnect mid-stream → Disconnected → Connecting(2) → resume from
  lastSeen cursor (no duplicate)
- exhausted attempts → Failed + 0 events
- close() → background loop cancelled, no further emissions

client/README.md: new 'Auto-reconnect для живого outbox' section with
usage example (two parallel scope.launch blocks) + parameter table.

jvmTest green (95 tasks, includes 4 new ReconnectingOutboxTest cases).
2026-09-22 02:55:19 +03:00
subochev 29851c047a docs(client): document persistence contract for AgentikAgent consumers
Android-client review item 8: AgentikAgent(id, baseUrl, engineFactory,
token) constructor was well-documented per parameter but lacked guidance
on what client must persist locally. AgentSettingsRepository rejected —
UI frameworks persist settings differently (JSON file, Keychain, Android
DataStore, NSUserDefaults), lib doesn't impose format.

KDoc on AgentikAgent now contains table of {clientId, baseUrl, token}
with where each comes from and the critical constraint that clientId
must be generated once on first install (UUID.randomUUID().toString())
and never changed — otherwise log multiplexing on the server breaks.
client/README.md 'persistence' section mirrors this for offline reading
with a minimal JSON example.

KDoc-only change. No code, no API surface.
2026-09-22 02:55:08 +03:00
subochev c9995b263e refactor(protocol): add toolName to ToolResult, remove proto typealiases, rename id→toolCallId, drop Conversation.events()
Three protocol-level changes from Android-client review (items 1-3, 5-6):

1) toolName denormalization in ToolResult (3 layers):
   - :outbox-api/Event.ToolResult: +toolName: String? = null
   - :journal-api/MessageRecord.ToolResult: +toolName: String? = null
   - :proto/Message.ToolResult: +toolName: String? = null
   - :storage-ksqlite, :journal-ksqlite ResultPayload codec: +toolName
   - :standalone/ToolDispatcher, ConversationLoop: thread toolName = call.name
   Nullable + default = backward-compat for already-persisted histories
   and existing clients.

2) Drop proto/Event.kt, AgentEvent.kt, CommonEvent.kt typealiases.
   is proto.Event.End failed with 'Unresolved reference End' (alias
   loses nested-class access). Use pw.binom.agentik.outbox.{Event,
   AgentEvent, CommonEvent} directly everywhere — :proto already has
   api(:outbox-api), the package is visible to consumers, no shim
   needed. 21 files rewired, 3 files deleted.

3) Rename Event.ToolResult.id → toolCallId (option B per user).
   In :outbox-api Event.ToolResult.id == Event.ToolCall.id (one value,
   one name); the persistent journal keeps MessageRecord.ToolResult.id
   as its own PK + toolCallId as FK to the call — different semantics,
   left untouched. Fixed ToolDispatcher bug: emitted id = resultId
   while KDoc claimed id == ToolCall.id; now emits toolCallId = callId.

4) Remove Conversation.events() from :proto; OutboxStore is sole event source.
   Conversation is a pure per-conversation abstraction (send/getMessages/
   rename/close). Live events only via agent.outbox.conversationEvents/
   agentEvents/events. HTTP route /conversations/{id}/events stays for
   wire-compat but routes through outbox internally (map { it.event }).

jvmTest green (95 tasks).
2026-09-22 02:55:02 +03:00
subochev 7865eed836 fix(ci): remove :agentik-cli shadowJar step (модуль отключён 2026-09-21)
ci / JVM build + tests (push) Successful in 6m20s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 37s
ci.yml пытался собрать :agentik-cli:shadowJar, но проект
закомментирован в settings.gradle.kts. Это ломало build-jvm job
с 'project agentik-cli not found'. Убираем шаг.

Дополнительно: text-embedding-kmp 4 → 5 (v4 в caffeine имел только
jvm+android варианты — native-resolve падал в :memory-md-vector). v5
содержит полный набор klib'ов (linuxX64/Arm64, mingwX64, macosX64/Arm64,
iosX64/Arm64/SimulatorArm64).
2026-09-21 23:18:45 +03:00
subochev 0a7c40688c feat(memory): add :memory-md-vector hybrid backend + :reflection-api + outbox/journal entity splits
ci / JVM build + tests (push) Failing after 2m59s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 9s
- :memory-md-vector (KMP jvm+linuxX64+mingwX64): .md-файлы как source of
  truth, векторный индекс (sqlite-vec) как derived cache. reconcile()
  на старте: orphan-cleanup + content-hash-gated re-embed. Гибридный
  скор 0.7*vector + 0.3*keyword. Заменяет EmbeddingProvider на
  KMP-TextEmbeddingExecutor из :memory-api.

- :reflection-api: новый 4-й API-модуль (Reflection, ReflectionStore,
  ReflectionEvent). Зависит только от :memory-api.

- :journal-api получил ConversationRecord/ConversationStore/Ids (бывший
  :message-store-api, полностью удалён). :outbox-api получил Event,
  CommonEvent, AgentEvent (бывший :event-store).

- :memory-api получил MemoryVectorIndex + NoteMatches +
  TextEmbeddingExecutor (suspend-обёртка над TextEmbeddingExtractor).

- :memory-vector KMP-цели достигнуты через commonMain-only TextEmbedding-
  Executor, EmbeddingProvider выпилен; :memory-md-vector тянет
  text-embedding-api транзитивно через :memory-api.

- :standalone flatten в commonMain/commonTest завершён (тесты из jvmTest
  переехали в commonTest). Включён optional деп :memory-md-vector через
  AGENTIK_MEMORY_BACKEND=md-vector.

jvmTest: 96 задач, 407 тестов, 0 падений.
2026-09-21 23:10:28 +03:00
subochev 161be41adf fix(deps): bump ksqlite 0.1.1-SNAPSHOT → 0.1.2, text-embedding-kmp → v4
ksqlite 0.1.2 опубликован в Maven Central (был только в локальном ~/.m2);
text-embedding-kmp v4 — в caffeine Nexus (поддержка нативных целей):
jvm, android, linuxX64/Arm64, macosX64/Arm64, iosX64/Arm64/SimulatorArm64.

Без этих апдейтов CI release-пайплайн падал с unresolved-dependencies
на любом свежем коммите после acc7237e5 (введение ksqlite).

Дополнительно: игнорируем локальный opencode config.json.
2026-09-21 23:10:19 +03:00
subochev 68543357c2 feat(memory): migrate EmbeddingProvider to KMP-compatible TextEmbeddingExecutor, add :memory-md-vector, and hybrid backend support
ci / JVM build + tests (push) Failing after 11s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 10s
- Replaced `EmbeddingProvider` with cross-platform `TextEmbeddingExecutor` for native target compatibility.
- Introduced `:memory-md-vector` module combining vector-cache and `.md` file-based memory systems (`hybrid` backend).
- Updated `SiglipEmbeddingProvider` to use KMP `TextEmbeddingExtractor` and streamlined compatibility via `asExecutor`.
- Added hybrid memory backend to `standalone`, supporting `.md` reconciliation with vector-cache for semantic
2026-09-21 12:28:09 +03:00
203 changed files with 11135 additions and 2906 deletions
+7 -9
View File
@@ -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"
+2
View File
@@ -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 структура)
+7 -6
View File
@@ -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'ы + крепит артефакты к релизу.
-37
View File
@@ -1,37 +0,0 @@
Status of message-log-api migration:
DONE:
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
- Content.kt (sealed: Text, Image)
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
- MessageStore.kt (interface, TokenStats, MessageEvent)
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
6. Wrote /tmp/run_rename.sh that runs the python script.
PENDING:
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
- Verify build compiles (run standalone tests).
Files that need import updates (per search):
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
- standalone/src/jvmMain/.../ToolDispatcher.kt
- standalone/src/jvmMain/.../ConversationLoop.kt
- standalone/src/jvmTest/.../ChatAgentTest.kt
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
Workaround needed before continuing the migration.
+42
View File
@@ -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>
}
+3
View File
@@ -24,9 +24,12 @@ kotlin {
api(project(":journal-api")) api(project(":journal-api"))
api(project(":reflection-api")) api(project(":reflection-api"))
api(project(":context-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)
@@ -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()
} }
} }
@@ -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) ?: ""
}
}
@@ -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)
}
} }
@@ -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)
}
} }
@@ -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"
} }
@@ -27,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
// Подписываемся на поток событий ДО send: события, отправленные // Подписываемся на поток событий ДО send: события, отправленные
// до подписки, не реплеятся (shared-flow без replay). // до подписки, не реплеятся (shared-flow без replay).
val eventsJob = launch { val eventsJob = launch {
conv.events(Instant.DISTANT_PAST) agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
// onEach печатает и терминальный event, takeWhile лишь // onEach печатает и терминальный event, takeWhile лишь
// завершает сбор после него. // завершает сбор после него.
.map { it.event }
.onEach { ev -> emit(ev) } .onEach { ev -> emit(ev) }
.takeWhile { ev -> !isTerminal(ev) } .takeWhile { ev -> !isTerminal(ev) }
.collect { } .collect { }
@@ -53,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
is Event.AppendText -> println("event AppendText ${escape(ev.body)}") is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>") is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}") is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}") is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
is Event.End -> println("event End") is Event.End -> println("event End")
is Event.Interrupted -> println("event Interrupted") is Event.Interrupted -> println("event Interrupted")
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}") is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
@@ -7,7 +7,7 @@ import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -92,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,12 +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.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
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 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
@@ -31,10 +32,13 @@ internal class FakeAgent(
// emptyFlow, journal — error-on-access (никто не должен его трогать). // emptyFlow, journal — error-on-access (никто не должен его трогать).
override val journal: JournalStore = error("journal not used in TuiBackend tests") override val journal: JournalStore = error("journal not used in TuiBackend tests")
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.proto.CommonEvent>() override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {} 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++
@@ -49,8 +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()
} }
/** /**
@@ -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
View File
@@ -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)."
+192 -4
View File
@@ -16,6 +16,11 @@
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>` - `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens). со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE). - `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
с основным потоком событий. См. ниже.
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно `Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины. вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
@@ -36,6 +41,30 @@ dependencies {
} }
``` ```
## Что клиент хранит локально (persistence)
Либа **не** имеет `SettingsRepository` / `Config` — это намеренно: UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain, Android DataStore, NSUserDefaults, ...). Либа не навязывает формат, но клиент должен сериализовать у себя минимум:
| Поле | Что это | Где взять |
|---|---|---|
| `clientId` (параметр `id` в `AgentikAgent`) | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id — это **произвольная строка клиента**, обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется один раз при первом запуске (`UUID.randomUUID().toString()`) и сохраняется. Никогда не меняется. |
| `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
| `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
Опционально (для UX): `engineFactory` — обычно compile-time выбор по платформе (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS).
Минимальный JSON для UI, который хранит в файле:
```json
{
"clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
"baseUrl": "https://agent.example.com/agentik",
"token": "s3cret"
}
```
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере.
## Быстрый старт: свой клиент за 5 минут ## Быстрый старт: свой клиент за 5 минут
Один self-contained пример: создаём агента, открываем диалог, Один self-contained пример: создаём агента, открываем диалог,
@@ -256,6 +285,55 @@ session.scope.launch {
`rec is MessageRecord.UserMessage` для реплик пользователя, `rec is MessageRecord.UserMessage` для реплик пользователя,
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п. `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-ответа ## Стриминг live-ответа
Для streaming-рендера текущего хода подписывайся на `events()` и Для streaming-рендера текущего хода подписывайся на `events()` и
@@ -308,13 +386,81 @@ UI-обновление списка — отдельная задача, реш
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только - **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
отдаёт типы и потоки. отдаёт типы и потоки.
- **Персистентность кэша** — `InMemoryJournalStore` хранит в RAM. Для - **Персистентность кэша** — `InMemoryJournalStore` и
диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в `InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
`:journal-ksqlite` как образец). `MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
в `:journal-ksqlite` как образец).
- **Нестандартные движковые настройки** — для `requestTimeout`, - **Нестандартные движковые настройки** — для `requestTimeout`,
прокси и т.п. используй `agentikHttpClient(engineFactory, token)` прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
напрямую. напрямую.
## Кэш списка бесед
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
в `localStore.upsert(...)`.
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
- `Deleted(id)` → `local.delete(id)`
- `Renamed(id, title)` → `local.rename(id, title)`
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
клиент видит их через SSE и применяет к локальной копии.
**Команды** (создать / переименовать / удалить) идут через `agent`:
```kotlin
// Создать новую беседу:
val conv = agent.createConversation(temp = false) // → POST /conversations
// → server эмитит Created
// → client cache получает Created
// → UI увидит её в списке
// Переименовать:
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
// → server эмитит Renamed
// → client cache обновляет title
// Удалить:
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
// → server эмитит Deleted
// → client cache удаляет запись
```
`conversationStore` доступен **только для чтения**. Это read-only projection
на серверную таблицу `conversation` (id + title + timestamps). Для активной
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
командами `agent.createConversation / deleteConversation / renameConversation`.
### Если хочется своего cache-импла
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:journal-ksqlite` как образец.
```kotlin
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ConversationRecord
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
override suspend fun delete(id: String): Boolean { /* DELETE */ }
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
override suspend fun touch(id: String, now: Instant) { /* UPDATE updatedAt */ }
override fun close() {}
}
```
## Тесты ## Тесты
``` ```
@@ -322,7 +468,49 @@ UI-обновление списка — отдельная задача, реш
``` ```
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва, Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
401/404. 401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
reconnect / exhausted attempts → Failed / close → cancel).
## Auto-reconnect для живого outbox
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
```kotlin
val recon = ReconnectingOutbox(
outbox = agent.outbox, // или HttpEventStore
scope = myScreenScope,
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
)
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
scope.launch {
recon.connectionStatus().collect { status ->
when (status) {
is Connecting -> ui.showBanner("connecting...")
is Connected -> ui.hideBanner()
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
is Failed -> ui.showError(status.cause)
}
}
}
// На выходе (например, navigation back):
recon.close() // отменяет background-loop, потоки терминируются
```
Два потока **независимы** — `events()` содержит только `CommonEvent`,
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
"мешающего" `Connecting`/`Disconnected` в потоке событий.
Параметры backoff (см. `BackoffPolicy`):
- `initial` / `max` — границы задержки
- `multiplier` — множитель на каждом шаге
- `jitter` — рандом-разброс (по умолчанию 20%)
- `maxAttempts` — лимит попыток; после — `Failed` + закрытие потока
Если нужен фиксированный delay для тестов — `BackoffPolicy.Fixed(10.milliseconds, attempts = 3)`.
## Известное ограничение ## Известное ограничение
+1
View File
@@ -23,6 +23,7 @@ kotlin {
api(project(":proto")) api(project(":proto"))
api(project(":outbox-api")) api(project(":outbox-api"))
api(project(":journal-api")) api(project(":journal-api"))
implementation(project(":journal-inmemory"))
api(libs.ktor.client.core) api(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
@@ -5,25 +5,28 @@ import io.ktor.client.call.body
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.get 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.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/** /**
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`. * HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
* *
* HttpClient создаётся внутри из переданного engine и закрывается в [close]. * HttpClient создаётся внутри из переданного engine и закрывается в [close].
* *
* **Storage handles** ([journal], [outbox]) — read-only views на серверные * **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
* хранилища. * views на серверные хранилища. Запись — только через команды
* [createConversation] / [deleteConversation] / [renameConversation].
*/ */
internal class AgentClient( internal class AgentClient(
override val id: String, override val id: String,
@@ -35,6 +38,7 @@ internal class AgentClient(
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl) override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
override val journal: JournalStore = HttpJournalStore(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 {
@@ -53,16 +57,18 @@ internal class AgentClient(
} }
override suspend fun deleteConversation(id: String): Boolean { override suspend fun deleteConversation(id: String): Boolean {
val response: HttpResponse = httpClient.delete("$agentUrl/conversations/$id") val response = httpClient.delete("$agentUrl/conversations/$id")
return response.status == HttpStatusCode.NoContent 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 close() { override fun close() {
@@ -1,7 +1,20 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.engine.HttpClientEngineFactory 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` (модуль `:server`). * Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
@@ -20,24 +33,141 @@ import pw.binom.agentik.proto.Agent
* ) * )
* val conv = agent.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 { ... } * agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
* agent.close() // закрывает HttpClient * .map { it.event }
* .collect { ... }
* agent.close() // закрывает HttpClient + локальный кэш
* ``` * ```
* *
* [id] пробрасывается в `Agent.id` — сервер про идентичность агента не знает, * ## Что клиент должен хранить локально (persistence)
* поэтому клиент должен её знать сам (или взять из конфига). *
* Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
* UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain,
* `SharedPreferences`, Android DataStore, NSUserDefaults, ...). Либа
* не навязывает формат, но вот минимальный набор, который клиент должен
* сериализовать у себя, чтобы пережить перезапуск:
*
* | Поле | Что это | Где взять |
* |---|---|---|
* | `id` | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id, а произвольная строка клиента — обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется клиентом при первом запуске, сохраняется локально |
* | `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
* | `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
*
* Опционально (для UX):
* | Поле | Зачем |
* |---|---|
* | `engineFactory` | Зависит от платформы (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS). Выбор — обычно compile-time. |
*
* Пример минимального persistence-файла (для UI, который хранит JSON):
*
* ```json
* {
* "clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
* "baseUrl": "https://agent.example.com/agentik",
* "token": "s3cret"
* }
* ```
*
* `clientId` генерируется один раз при первой установке (`UUID.randomUUID().toString()`)
* и больше не меняется — иначе сломается log multiplexing на сервере.
*
* ## Локальный кэш списка бесед
*
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
* Внутри лежит [InMemoryMutableConversationStore], который:
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`.
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
* соответствующий `upsert/delete/rename/touch` к локальной копии.
*
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
* через [Agent] и **не** через `conversationStore` (он read-only).
* *
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает * **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
* HttpClient (идемпотентно). После этого `createConversation` / * HttpClient + локальный кэш + background-coroutine (идемпотентно).
* `getConversation` etc. не определены. * После этого `createConversation` / `getConversation` etc. не определены.
*/ */
fun AgentikAgent( fun AgentikAgent(
id: String, id: String,
baseUrl: String, baseUrl: String,
engineFactory: HttpClientEngineFactory<*>, engineFactory: HttpClientEngineFactory<*>,
token: String? = null, token: String? = null,
): Agent = AgentClient( ): Agent {
id = id, val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
baseUrl = baseUrl, val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient)
httpClient = agentikHttpClient(engineFactory = engineFactory, token = token), return wrapWithLocalConversationCache(client, scopeClient = client)
) }
/**
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
* локальным in-memory кэшем, синхронизированным с удалённым стором
* через outbox-события.
*
* - **Seed**: при создании делает один snapshot через
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
*
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
* (на read-only projection локального [InMemoryMutableConversationStore]).
* Остальные методы [Agent] — delegated в [delegate].
*/
private fun wrapWithLocalConversationCache(
delegate: Agent,
scopeClient: Agent,
): Agent = object : Agent by delegate {
private val localStore: MutableConversationStore = InMemoryMutableConversationStore()
private val cacheScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val syncJob: Job
init {
// Делаем cacheStore read-only view на localStore.
// (Через вложенный класс — см. ниже.)
// Запускаем seed + live-refresh параллельно.
syncJob = cacheScope.launch {
// 1. seed — snapshot всех текущих бесед с сервера
try {
delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE)
.collect { rec -> localStore.upsert(rec) }
} catch (_: Throwable) {
// seed может упасть (offline / 5xx) — не критично,
// live-источник всё равно догонит при первом событии.
}
// 2. live — применяем outbox-события.
// Используем `first()` для knownId после Created — потом отписываемся,
// потому что Created нужно вытянуть полный record через `remote.get(id)`.
// Renamed/Touched меняют локальную копию без round-trip.
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce ->
when (val ev = ce.event) {
is AgentEvent.Created -> {
// Created не несёт title/timestamps — нужно сходить в remote.
val rec = delegate.conversationStore.get(ev.conversationId)
if (rec != null) localStore.upsert(rec)
}
is AgentEvent.Deleted -> localStore.delete(ev.id)
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
}
}
}
}
/**
* Read-only projection локального кэша — клиент через него только
* читает (`get` / `list` / `listFlow`).
*/
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
override fun close() {} // owned by outer close
}
override fun close() {
cacheScope.cancel()
runBlocking { syncJob.join() }
delegate.close()
}
}
@@ -6,18 +6,12 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.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?)
@@ -0,0 +1,58 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.HttpStatusCode
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
/**
* HTTP-реализация [ConversationStore] (read-only metadata view),
* ходящая в `:server`-фасад.
*
* **Endpoint**: `GET {baseUrl}/conversations?offset=&limit=` —
* возвращает `List<ConversationRecord>` (id, title, isTemporal, createdAt,
* updatedAt) БЕЗ handle'ов и image-support флагов (это лёгкая проекция
* для UI-списка; handle берётся через `agent.getConversation(id)`).
*
* **Read-only**: запись в `conversation` table — только через команды
* `agent.createConversation / deleteConversation / renameConversation`.
*
* Клиентский кэш строится композицией `HttpConversationStore` (snapshot)
* + `agent.outbox.agentEvents(after)` (live deltas: Created/Deleted/
* Renamed/Touched) — см. `client/README.md` секция
* «Кэш списка бесед».
*/
internal class HttpConversationStore(
private val httpClient: HttpClient,
private val baseUrl: String,
) : ConversationStore {
private val agentUrl: String = baseUrl.trimEnd('/')
override suspend fun get(id: String): ConversationRecord? {
val response = httpClient.get("$agentUrl/conversations/$id")
if (response.status == HttpStatusCode.NotFound) return null
check(response.status == HttpStatusCode.OK) {
"conversationStore.get($id): server returned ${response.status}"
}
return response.body<ConversationRecord>()
}
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> {
val response = httpClient.get("$agentUrl/conversations") {
parameter("offset", offset)
parameter("limit", limit)
}
check(response.status == HttpStatusCode.OK) {
"conversationStore.list: server returned ${response.status}"
}
return response.body<List<ConversationRecord>>()
}
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@@ -0,0 +1,242 @@
package pw.binom.agentik.client
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.math.min
import kotlin.math.pow
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Состояние подключения к удалённому [OutboxStore]. Эмитится через
* [ReconnectingOutbox.connectionStatus] — отдельным потоком, **не**
* смешивается с [ReconnectingOutbox.events].
*
* Типичный цикл:
* ```
* Connecting(1) → Connected → ... → Disconnected(reason, retryIn) →
* Connecting(2) → Connected → ...
* ```
* При полном исчерпании попыток ([BackoffPolicy.maxAttempts]) —
* финальный [Failed].
*/
sealed interface ConnectionStatus {
/** Начата попытка подключения (включая первую — `attempt == 1`). */
data class Connecting(val attempt: Int) : ConnectionStatus
/** Получен первый event с сервера после [Connecting] / [Disconnected]. */
data class Connected(val since: Instant) : ConnectionStatus
/**
* Стрим оборвался (network error, server close, таймаут). [reason] —
* причина, `null` если штатное завершение. [willRetryIn] — через сколько
* будет следующая попытка (`null` если [Failed]).
*/
data class Disconnected(
val reason: Throwable?,
val willRetryIn: Duration?,
) : ConnectionStatus
/**
* Все попытки исчерпаны ([BackoffPolicy.maxAttempts]). Поток [events]
* закрывается после этого. Создатель [ReconnectingOutbox] должен
* решить, что делать — показать ошибку пользователю, пересоздать
* outbox и т.п.
*/
data class Failed(val cause: Throwable) : ConnectionStatus
}
/**
* Политика backoff для [ReconnectingOutbox]. Параметры:
*
* - [initial] — задержка перед первой retry-попыткой.
* - [max] — потолок задержки (после серии умножений).
* - [multiplier] — множитель на каждом шаге (например, `2.0` → 1s, 2s, 4s, 8s, ...).
* - [jitter] — доля случайного разброса `[0, jitter]` от текущей задержки
* (например, `0.2` = ±20%). Снижает thundering-herd при массовом reconnect.
* - [maxAttempts] — лимит попыток. `Int.MAX_VALUE` = бесконечно.
*/
data class BackoffPolicy(
val initial: Duration = 1.seconds,
val max: Duration = 30.seconds,
val multiplier: Double = 2.0,
val maxAttempts: Int = Int.MAX_VALUE,
val jitter: Double = 0.2,
) {
init {
require(initial > Duration.ZERO) { "initial must be positive" }
require(max >= initial) { "max must be >= initial" }
require(multiplier >= 1.0) { "multiplier must be >= 1.0" }
require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
require(jitter in 0.0..1.0) { "jitter must be in [0, 1]" }
}
companion object {
/** 1s → 2s → 4s → ... → 30s, jitter ±20%, бесконечные попытки. */
val Default: BackoffPolicy = BackoffPolicy()
/** Только для тестов: фиксированные задержки без разброса. */
fun Fixed(delay: Duration, attempts: Int = 3): BackoffPolicy =
BackoffPolicy(
initial = delay,
max = delay,
multiplier = 1.0,
maxAttempts = attempts,
jitter = 0.0,
)
}
}
/**
* Обёртка над [OutboxStore] с автоматическим reconnect при обрыве стрима.
*
* **Два независимых потока**:
* - [events] — `Flow<CommonEvent>`, тот же контракт что [OutboxStore.events],
* но с автоматическим переподключением через [BackoffPolicy]. Cursor
* (`lastSeen`) сохраняется между попытками — клиент не теряет события.
* - [connectionStatus] — `Flow<ConnectionStatus>`, **параллельный** поток
* lifecycle подключения. Не смешивается с [events].
*
* ```
* val outbox = ReconnectingOutbox(httpEventStore, scope)
*
* scope.launch {
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) }
* }
* scope.launch {
* outbox.connectionStatus().collect { s -> ui.showStatus(s) }
* }
*
* // На выходе:
* outbox.close() // отменяет background-loop, эмитит Cancelled-как-Disconnected
* ```
*
* Создатель передаёт свой [scope] — жизненный цикл reconnect-цикла
* привязан к нему. Закрытие scope (или явный [close]) отменяет
* background-loop. После [close] оба flow терминируются.
*/
class ReconnectingOutbox(
private val outbox: OutboxStore,
private val scope: CoroutineScope,
private val policy: BackoffPolicy = BackoffPolicy.Default,
private val random: Random = Random.Default,
) : AutoCloseable {
private val _events = MutableSharedFlow<CommonEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val _status = MutableSharedFlow<ConnectionStatus>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@OptIn(ExperimentalAtomicApi::class)
private val started = AtomicBoolean(false)
private var job: Job? = null
@OptIn(ExperimentalAtomicApi::class)
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null)
/**
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
* учитывается только при первом вызове (любом из [events] /
* [connectionStatus]). После reconnect курсор берётся из `date`
* последнего виденного события.
*
* Коллекторы независимы — каждый получает свою копию потока (shared).
* Медленный коллектор может пропускать события при переполнении буфера
* (`DROP_OLDEST`).
*/
fun events(after: Instant? = null): Flow<CommonEvent> {
ensureStarted(after)
return _events
}
/**
* Lifecycle подключения: [ConnectionStatus.Connecting] /
* [ConnectionStatus.Connected] / [ConnectionStatus.Disconnected] /
* [ConnectionStatus.Failed]. **Не смешивается** с [events] — это
* отдельный поток для UI-индикации статуса сети.
*/
fun connectionStatus(): Flow<ConnectionStatus> {
ensureStarted(null)
return _status
}
@OptIn(ExperimentalAtomicApi::class)
private fun ensureStarted(initialCursor: Instant?) {
if (!started.compareAndSet(false, true)) return
lastSeen.store(initialCursor)
job = scope.launch { runLoop() }
}
@OptIn(ExperimentalAtomicApi::class)
private suspend fun runLoop() {
var attempt = 0
var connected = false
while (currentCoroutineContext().isActive) {
attempt++
_status.emit(ConnectionStatus.Connecting(attempt))
val error: Throwable? = try {
outbox.events(after = lastSeen.load()).collect { event ->
lastSeen.store(event.date)
_events.emit(event)
if (!connected) {
connected = true
_status.emit(ConnectionStatus.Connected(event.date))
}
}
null
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
t
}
connected = false
if (attempt >= policy.maxAttempts) {
_status.emit(
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
)
return
}
val backoff = computeBackoff(attempt)
_status.emit(ConnectionStatus.Disconnected(error, backoff))
delay(backoff)
}
}
private fun computeBackoff(attempt: Int): Duration {
// attempt 1 → initial, 2 → initial * m, 3 → initial * m^2, ...
val base = (policy.initial.inWholeMilliseconds.toDouble() *
policy.multiplier.pow((attempt - 1).toDouble()))
.toLong()
val capped = min(base, policy.max.inWholeMilliseconds)
val jitterMs = (capped * policy.jitter * random.nextDouble()).toLong()
val finalMs = (capped + jitterMs).coerceAtLeast(1L)
return Duration.parse("${finalMs}ms")
}
override fun close() {
job?.cancel()
job = null
}
}
@@ -0,0 +1,211 @@
package pw.binom.agentik.client
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.outbox.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* In-memory [OutboxStore] для unit-тестов [ReconnectingOutbox].
*
* Управление:
* - [push] — кладёт [CommonEvent] в очередь, флоу доставит.
* - [throwAtNextEvent] — следующий «тик» `events(after)` бросит этот Throwable
* (симулирует network error / stream break).
*
* Сигнатура [events] идентична боевой — её можно подменить боевым
* `HttpEventStore`, контракт один и тот же.
*/
internal class FakeOutbox : OutboxStore {
private sealed interface Msg {
data class Ev(val event: CommonEvent) : Msg
data class Err(val throwable: Throwable) : Msg
}
private val channel = Channel<Msg>(Channel.UNLIMITED)
override fun events(after: Instant?): Flow<CommonEvent> = flow {
for (msg in channel) {
when (msg) {
is Msg.Err -> throw msg.throwable
is Msg.Ev -> emit(msg.event)
}
}
}
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow()
override fun conversationEvents(
after: Instant?,
conversationId: String?,
): Flow<CommonEvent.Conversation> = emptyFlow()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() { channel.close() }
}
private fun testEvent(dateMs: Long): CommonEvent =
CommonEvent.Conversation(
date = Instant.fromEpochMilliseconds(dateMs),
conversationId = "test",
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
)
@OptIn(ExperimentalCoroutinesApi::class)
class ReconnectingOutboxTest {
@Test
fun `first event after connect emits Connecting then Connected`() = runConnectionTest(
attempts = 5,
) { ctx ->
val fake = ctx.fake
val status = ctx.statusLog
val events = ctx.eventsLog
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, status.count { it is ConnectionStatus.Connecting && it.attempt == 1 })
assertEquals(1, status.count { it is ConnectionStatus.Connected })
assertEquals(1, events.size)
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
}
@Test
fun `disconnect mid-stream triggers retry with backoff and resumes from last seen`() =
runConnectionTest(attempts = 5) { ctx ->
val fake = ctx.fake
val status = ctx.statusLog
val events = ctx.eventsLog
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, events.size)
// Имитируем обрыв стрима после первого события.
fake.throwAtNextEvent(RuntimeException("simulated network error"))
ctx.advanceAndDrain(50)
// После Disconnected должен прийти Connecting(2), затем Connected,
// затем новые события без дубля предыдущего.
val disconnectedIndex = status.indexOfFirst { it is ConnectionStatus.Disconnected }
val connecting2Index = status.indexOfFirst {
it is ConnectionStatus.Connecting && it.attempt == 2
}
assertTrue(disconnectedIndex >= 0, "no Disconnected emitted, got: $status")
assertTrue(connecting2Index > disconnectedIndex,
"expected Connecting(2) after Disconnected, got: $status")
// Push a new event with later date — cursor preserves lastSeen.
fake.push(testEvent(2000))
ctx.advanceAndDrain(50)
assertEquals(2, events.size)
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
assertEquals(Instant.fromEpochMilliseconds(2000), events[1].date)
}
@Test
fun `exhausted attempts emits Failed and closes flow`() = runConnectionTest(
attempts = 3,
) { ctx ->
val fake = ctx.fake
// Каждая попытка connect бросает — все 3 попытки fail.
for (i in 0 until 3) {
fake.throwAtNextEvent(RuntimeException("server is dead #${i + 1}"))
ctx.advanceAndDrain(50)
}
val failed = ctx.statusLog.filterIsInstance<ConnectionStatus.Failed>().firstOrNull()
assertNotNull(failed) { "expected Failed status, got: ${ctx.statusLog}" }
assertTrue(failed.cause is RuntimeException)
assertEquals(0, ctx.eventsLog.size)
}
@Test
fun `close cancels background loop`() = runConnectionTest(
attempts = 5,
) { ctx ->
val fake = ctx.fake
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, ctx.eventsLog.size)
ctx.recon.close()
ctx.advanceAndDrain(100)
// После close запуск новых эмиссий не должен происходить.
val beforePush = ctx.eventsLog.size
fake.push(testEvent(2000))
ctx.advanceAndDrain(100)
assertEquals(beforePush, ctx.eventsLog.size)
}
private data class TestCtx(
val fake: FakeOutbox,
val recon: ReconnectingOutbox,
val statusLog: MutableList<ConnectionStatus>,
val eventsLog: MutableList<CommonEvent>,
val jobs: List<Job>,
val scope: CoroutineScope,
val advanceAndDrain: (Long) -> Unit,
)
/**
* Запускает [ReconnectingOutbox] с policy из `attempts` попыток по 10ms,
* сабскрайбит на оба потока в собирающие лист, и возвращает [TestCtx]
* с управляемым `advanceAndDrain(ms)` — прокрутить виртуальное время.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun runConnectionTest(
attempts: Int,
block: suspend (TestCtx) -> Unit,
) = runTest {
val policy = BackoffPolicy.Fixed(
delay = Duration.parse("10ms"),
attempts = attempts,
)
val fake = FakeOutbox()
val recon = ReconnectingOutbox(
outbox = fake,
scope = this,
policy = policy,
)
val statusLog = mutableListOf<ConnectionStatus>()
val eventsLog = mutableListOf<CommonEvent>()
val jobs = listOf(
launch { recon.connectionStatus().collect { statusLog.add(it) } },
launch { recon.events().collect { eventsLog.add(it) } },
)
val advanceAndDrain: (Long) -> Unit = { ms ->
if (ms > 0) advanceTimeBy(ms)
runCurrent()
}
try {
TestCtx(fake, recon, statusLog, eventsLog, jobs, this, advanceAndDrain).also { block(it) }
} finally {
recon.close()
jobs.forEach { it.cancel() }
fake.close()
}
}
}
+4 -2
View File
@@ -10,7 +10,7 @@ plugins {
// агента. // агента.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -21,7 +21,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT") // ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
api(project(":context-api")) api(project(":context-api"))
@@ -17,20 +17,41 @@ import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [ContextStore] (таблица `working_memory`). * ksqlite-реализация [ContextStore] (таблица `working_memory`).
* *
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore] * Единственный владелец таблицы `working_memory` в проекте. Используется
* из `:storage-ksqlite`, но: * напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
* - лежит в собственном модуле `:context-ksqlite`; * `pw.binom.agentik.standalone.persistence.SqliteStores`.
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
* теперь главный класс — `ContextStore`); сами типы строк
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
* *
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске — * ## Lifecycle соединения
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно. *
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close].
* - `KsqliteContextStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
*/ */
class KsqliteContextStore( class KsqliteContextStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : ContextStore { ) : 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 mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -179,6 +200,15 @@ class KsqliteContextStore(
maxOrderIdxStmt.close() maxOrderIdxStmt.close()
dropFromIdxStmt.close() dropFromIdxStmt.close()
insertSummaryStmt.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 { private fun maxOrderIdx(conversationId: String): Long {
@@ -12,7 +12,7 @@ import pw.binom.db.ksqlite.SQLiteConnection
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких * Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е. * хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/ */
internal object Schema { object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1 const val CURRENT_VERSION: Int = 1
@@ -12,16 +12,9 @@ import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Тесты для [KsqliteContextStore] — точная копия * Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием * SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для * прогоняет `Schema.migrate` в init, явный вызов не нужен.
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
* та же).
*
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
* автономный.
*/ */
class KsqliteContextStoreTest { class KsqliteContextStoreTest {
@@ -31,7 +24,6 @@ class KsqliteContextStoreTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}") conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteContextStore(conn) store = KsqliteContextStore(conn)
} }
+1 -1
View File
@@ -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)
+16 -5
View File
@@ -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" }
@@ -0,0 +1,14 @@
package pw.binom.agentik.journal
import kotlin.time.Instant
/**
* Snapshot диалога. В таблице `conversation` хранится как есть.
*/
data class ConversationRecord(
val id: String,
val title: String?,
val isTemporal: Boolean,
val createdAt: Instant,
val updatedAt: Instant,
)
@@ -0,0 +1,45 @@
package pw.binom.agentik.journal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* Read-only view of the `conversation` table (CRUD-операции находятся
* в [MutableConversationStore] и используются только внутри ChatAgent).
*
* Клиенты видят [ConversationStore] через [pw.binom.agentik.proto.Agent.conversationStore]
* (по аналогии с `journal` / `outbox`) и строят свой локальный кэш:
* - **seed** через [list] (snapshot страницы) или [listFlow] (cold-flow paging);
* - **live-refresh** через `outbox.agentEvents()` — Created / Deleted /
* Renamed / Touched.
*
* Запись в хранилище **не** делается клиентом — только команды
* `agent.createConversation / deleteConversation / renameConversation`.
*/
interface ConversationStore : AutoCloseable {
/** Диалог по id, или `null`. */
suspend fun get(id: String): ConversationRecord?
/** Список диалогов, отсортированный по `updatedAt` DESC. */
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
/**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для
* HTTP-импл — это лишние round-trip'ы; реализация может переопределить.
*/
fun listFlow(offset: Int = 0, pageSize: Int = PAGE_SIZE): Flow<ConversationRecord> = flow {
var skip = offset
while (true) {
val page = list(skip, pageSize)
if (page.isEmpty()) break
page.forEach { emit(it) }
skip += page.size
}
}
companion object {
const val PAGE_SIZE: Int = 100
}
}
@@ -0,0 +1,14 @@
package pw.binom.agentik.journal
import kotlin.uuid.Uuid
/**
* Генератор id. Использует `kotlin.uuid.Uuid` из stdlib (KMP: jvm + native),
* чтобы не зависеть от `java.util.UUID` и подготовить код к linuxX64-сборке.
*
* Сохраняет формат `<prefix>-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` —
* `:server` его парсит как opaque string, без знания внутренней структуры.
*/
object Ids {
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
}
@@ -18,6 +18,28 @@ interface JournalStore : AutoCloseable {
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord> suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/**
* Сколько сообщений в диалоге [conversationId] всего.
*
* O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`),
* O(N) на in-memory (size простого list'а с фильтром по conversationId).
* Не зависит от cursor'а [after] — для total-размера диалога.
*/
suspend fun count(conversationId: String): Long
/**
* Сколько сообщений в диалоге [conversationId] создано **позже** [after]
* (строго `createdAt > after`, как и в [list]).
*
* O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для:
* - UI badge "N новых сообщений" — клиент знает последний `lastSeen`,
* сервер говорит `count(convId, after=lastSeen)`;
* - пагинации без получения самих записей: знаем лимит последней страницы,
* надо понять "есть ли ещё";
* - compaction-метрик: «сколько turn'ов осталось после cutoff».
*/
suspend fun count(conversationId: String, after: Instant): Long
/** /**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip * Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для * (по странице через `list()` пока не получит короткую страницу). Для
@@ -55,6 +55,14 @@ sealed interface MessageRecord {
override val id: String, override val id: String,
override val conversationId: String, override val conversationId: String,
val toolCallId: String, val toolCallId: String,
/**
* Имя тула, денормализованное из соответствующего `MessageRecord.ToolCall.toolName`.
* Денормализация экономна (одна строка в SQLite) и снимает с UI
* необходимость сопоставления `toolCallId → toolName`. `null` —
* безопасный backfill для записей до миграции или для сиротливых
* результатов без предшествующего `ToolCall`.
*/
val toolName: String? = null,
val result: String?, val result: String?,
override val createdAt: Instant, override val createdAt: Instant,
) : MessageRecord ) : MessageRecord
@@ -0,0 +1,21 @@
package pw.binom.agentik.journal
import kotlin.time.Instant
/**
* CRUD по таблице `conversation`.
*/
interface MutableConversationStore : ConversationStore {
/** Создать или обновить snapshot диалога. */
suspend fun upsert(record: ConversationRecord)
/** Удалить диалог (вместе с его сообщениями и working memory). */
suspend fun delete(id: String): Boolean
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
suspend fun rename(id: String, title: String?): Instant?
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
suspend fun touch(id: String, now: Instant)
}
@@ -14,4 +14,12 @@ package pw.binom.agentik.journal
*/ */
interface MutableJournalStore : JournalStore { interface MutableJournalStore : JournalStore {
suspend fun append(record: MessageRecord) suspend fun append(record: MessageRecord)
/**
* Удалить все сообщения диалога [conversationId]. Используется
* владельцем lifecycle диалога при его удалении (каскад из
* ChatAgent.deleteConversation). Append-only природа audit log'а
* не нарушается — это bulk-clear, а не редактирование.
*/
suspend fun clear(conversationId: String)
} }
+10 -6
View File
@@ -2,12 +2,10 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
} }
// KMP-реализация [MutableJournalStore] на `MutableList` + `Mutex` — для // KMP-реализация [MutableJournalStore] и [MutableConversationStore] на
// тестов, dev-режима, embedded-сценариев (Android core, CLI, in-process кэш // `MutableList`/`MutableMap` + `Mutex` — для тестов, dev-режима,
// в клиенте) и как образец для своей реализации. // embedded-сценариев (Android core, CLI, in-process кэш в клиенте) и как
// // образец для своей реализации.
// `list` фильтрует по `conversationId`+`createdAt>after` и сортирует
// по `createdAt ASC`. Paging — поверх отфильтрованного списка.
// //
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory. // Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
@@ -15,7 +13,13 @@ kotlin {
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64() linuxX64()
linuxArm64()
mingwX64() mingwX64()
sourceSets { sourceSets {
@@ -54,9 +54,17 @@ class InMemoryJournalStore : MutableJournalStore {
.toList() .toList()
} }
/** Сбросить кэш (например, когда диалог удалён). */ /** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
suspend fun clear(): Unit = mutex.withLock { override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.clear() records.removeAll { it.conversationId == conversationId }
}
override suspend fun count(conversationId: String): Long = mutex.withLock {
records.count { it.conversationId == conversationId }.toLong()
}
override suspend fun count(conversationId: String, after: Instant): Long = mutex.withLock {
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
} }
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */ /** Сколько записей сейчас в кэше. Для тестов/диагностики. */
@@ -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 kotlin.time.Clock
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.MutableConversationStore
import kotlin.time.Clock
import kotlin.time.Instant 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) {
@@ -66,13 +66,96 @@ class InMemoryJournalStoreTest {
} }
@Test @Test
fun `clear empties the cache`() = runTest { fun `clear empties a conversation only`() = runTest {
val store = InMemoryJournalStore() val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z") val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "x", t0)) 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()) assertEquals(1, store.size())
store.clear()
assertEquals(0, store.size())
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty()) assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
}
@Test
fun `count returns total per conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
assertEquals(0L, store.count("c1"))
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 1.seconds))
store.append(userMsg("m3", "c2", "c", t0 + 2.seconds))
assertEquals(2L, store.count("c1"))
assertEquals(1L, store.count("c2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun `count is unaffected by clear of another conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..20) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
store.append(userMsg("n1", "c2", "y", t0))
assertEquals(20L, store.count("c1"))
assertEquals(1L, store.count("c2"))
store.clear("c1")
assertEquals(0L, store.count("c1"))
assertEquals(1L, store.count("c2"))
}
@Test
fun `count after cursor excludes earlier messages`() = 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))
// strictly > t0
assertEquals(2L, store.count("c1", t0))
// strictly > t0+10s — only the last
assertEquals(1L, store.count("c1", t0 + 10.seconds))
// after last — empty
assertEquals(0L, store.count("c1", t0 + 20.seconds))
// distant past — all
assertEquals(3L, store.count("c1", Instant.DISTANT_PAST))
}
@Test
fun `count after cursor scopes to conversation`() = runTest {
val store = InMemoryJournalStore()
val t = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t))
store.append(userMsg("m2", "c2", "b", t))
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("c1", future))
assertEquals(0L, store.count("c2", future))
assertEquals(1L, store.count("c1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("c2", Instant.DISTANT_PAST))
}
@Test
fun `count agrees with list size`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..15) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
assertEquals(
store.list("c1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("c1"),
)
val cutoff = t0 + 7.seconds
assertEquals(
store.list("c1", cutoff, 0, 1000).size.toLong(),
store.count("c1", cutoff),
)
} }
} }
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.inmemory package pw.binom.agentik.journal.inmemory
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import kotlin.test.Test import kotlin.test.Test
@@ -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
} }
+4 -2
View File
@@ -9,7 +9,7 @@ plugins {
// собственных ksqlite-модулях. // собственных ksqlite-модулях.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -20,7 +20,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT") // ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
api(project(":journal-api")) api(project(":journal-api"))
@@ -14,31 +14,67 @@ import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [MutableJournalStore] (append-only audit log). * ksqlite-реализация [MutableJournalStore] (append-only audit log).
* *
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore] * Единственный класс для message-таблицы. Используется напрямую через
* из `:storage-ksqlite`, но: * `:journal-ksqlite` зависимость; bundle'ом собирает
* - лежит в собственном модуле `:journal-ksqlite`; * `pw.binom.agentik.standalone.persistence.SqliteStores`.
* - реализует переименованный [MutableJournalStore] (раньше был *
* `MutableMessageStore`, теперь главный класс — `JournalStore` / * ## Lifecycle соединения
* `MutableJournalStore`); сам тип записи [MessageRecord] не *
* переименовывался. * Три формы конструктора с разной семантикой владения:
* - `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) препарируются один раз в * Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого * конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже * GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]). * parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
* (см. [pw.binom.db.ksqlite.StmtHolder]).
* *
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding * `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат * helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом. * в [MessageCodecs.kt] рядом.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
* отдельно.
*/ */
class KsqliteJournalStore internal constructor( class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableJournalStore { ) : 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 mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -64,6 +100,16 @@ class KsqliteJournalStore internal constructor(
private val clearStmt: SQLitePreparedStatement = connection.prepare( private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?" "DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
) )
private val countAllStmt: SQLitePreparedStatement = connection.prepare(
"SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val countAfterStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
""".trimIndent()
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) { override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record) val (kind, payload) = encodeRecord(record)
@@ -94,13 +140,15 @@ class KsqliteJournalStore internal constructor(
listStmt.bindLong(4, offset.toLong()) listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>() val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs -> listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json)) while (rs.next()) {
out.add(rs.toMessageRecord(json))
}
} }
out out
} }
} }
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) { override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
clearStmt.reset() clearStmt.reset()
clearStmt.clearBindings() clearStmt.clearBindings()
@@ -109,9 +157,52 @@ class KsqliteJournalStore internal constructor(
} }
} }
override suspend fun count(conversationId: String): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAllStmt.reset()
countAllStmt.clearBindings()
countAllStmt.bindText(1, conversationId)
countAllStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override suspend fun count(conversationId: String, after: Instant): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAfterStmt.reset()
countAfterStmt.clearBindings()
countAfterStmt.bindText(1, conversationId)
countAfterStmt.bindLong(2, after.toEpochMilliseconds())
countAfterStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override fun close() { override fun close() {
insertStmt.close() insertStmt.close()
listStmt.close() listStmt.close()
clearStmt.close() clearStmt.close()
countAllStmt.close()
countAfterStmt.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,
)
} }
} }
@@ -1,9 +1,9 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -11,15 +11,52 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [ConversationStore]. Схема таблицы `conversation` живёт * ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
* в [Schema] (миграция через PRAGMA user_version) — этот класс только * в [Schema] (миграция через PRAGMA user_version) — этот класс только
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`. * готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
*
* Каскадное удаление связанных данных (message + working_memory) делает
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
* store знает только про свою таблицу.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
* store НЕ закрывает его в [close] (используется shared-connection
* bundle'ом `SqliteStores.assemble`).
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
* в [close].
*/ */
class KsqliteConversationStore( class KsqliteMutableConversationStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val messageStore: KsqliteMessageStore? = null, private val ownsConnection: Boolean,
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null, ) : MutableConversationStore {
) : ConversationStore {
/**
* Открывает файловое соединение через [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() private val mutex = Mutex()
@@ -125,8 +162,6 @@ class KsqliteConversationStore(
// Проверяем существование через raw query, НЕ через get() — get() тоже // Проверяем существование через raw query, НЕ через get() — get() тоже
// берёт mutex (не реентрант), что привело бы к deadlock. // берёт mutex (не реентрант), что привело бы к deadlock.
if (!execExists(id)) return@withContext false if (!execExists(id)) return@withContext false
messageStore?.clear(id)
workingMemoryStore?.clear(id)
deleteStmt.reset() deleteStmt.reset()
deleteStmt.clearBindings() deleteStmt.clearBindings()
deleteStmt.bindText(1, id) deleteStmt.bindText(1, id)
@@ -188,6 +223,15 @@ class KsqliteConversationStore(
renameStmt.close() renameStmt.close()
renameUpdatedAtStmt.close() renameUpdatedAtStmt.close()
touchStmt.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 { private fun execExists(id: String): Boolean {
@@ -10,10 +10,8 @@ import kotlin.time.Instant
/** /**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite. * Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
* *
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers * `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор. * не в каком-то внешнем общем модуле.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
*/ */
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) { internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload( is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
@@ -30,7 +28,7 @@ internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (r
) )
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString( is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
ResultPayload.serializer(), ResultPayload.serializer(),
ResultPayload(toolCallId = record.toolCallId, result = record.result), ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
) )
is MessageRecord.Error -> "error" to Json.encodeToString( is MessageRecord.Error -> "error" to Json.encodeToString(
ErrorPayload.serializer(), ErrorPayload.serializer(),
@@ -59,7 +57,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
} }
"tool_result" -> { "tool_result" -> {
val p = Json.decodeFromString(ResultPayload.serializer(), payload) val p = Json.decodeFromString(ResultPayload.serializer(), payload)
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, result = p.result, createdAt = createdAt) MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
} }
"error" -> { "error" -> {
val p = Json.decodeFromString(ErrorPayload.serializer(), payload) val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
@@ -72,8 +70,18 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
internal data class CallPayload(val name: String, val title: String?, val argsJson: String) 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 @kotlinx.serialization.Serializable
internal data class ResultPayload(val toolCallId: String, val result: String?) internal data class ResultPayload(
val toolCallId: String,
val toolName: String? = null,
val result: String?,
)
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
internal data class ErrorPayload(val message: String, val code: String?) internal data class ErrorPayload(val message: String, val code: String?)
@@ -5,32 +5,51 @@ import pw.binom.db.ksqlite.SQLiteConnection
/** /**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`. * Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
* *
* Минимум — только то, что относится к `message` (append-only audit log). * Владеет двумя таблицами:
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`) * - `conversation` — реестр диалогов агента (см. ConversationRecord);
* живут в других ksqlite-модулях. * - `message` — append-only audit log сообщений диалогов.
*
* `working_memory` и `reflection` живут в других ksqlite-модулях.
* *
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких * Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е. * хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/ */
internal object Schema { object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1 const val CURRENT_VERSION: Int = 1
// ───── Таблица ───── // ───── Таблицы ─────
const val TABLE_CONVERSATION = "conversation"
const val TABLE_MESSAGE = "message" const val TABLE_MESSAGE = "message"
// ───── Колонки ───── // ───── Колонки conversation ─────
const val COL_ID = "id" 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_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind" const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json" const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ───── // ───── Индексы ─────
const val IDX_CONV_UPDATED = "idx_conv_updated"
const val IDX_MSG_CONV = "idx_msg_conv" const val IDX_MSG_CONV = "idx_msg_conv"
private val v1Ddl = """ private val v1ConversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_TITLE TEXT,
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
"""
private val v1MessageDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE ( CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY, $COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL, $COL_CONVERSATION_ID TEXT NOT NULL,
@@ -38,54 +57,43 @@ internal object Schema {
$COL_PAYLOAD_JSON TEXT NOT NULL, $COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL $COL_CREATED_AT INTEGER NOT NULL
); );
""".trimIndent() """
private val v1IndexesDdl = """ 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 CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT); ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
""".trimIndent() """
/** /**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД. * Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
* *
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм, * Гарантии:
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция — * - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция * уже-мигрированной БД;
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии. * - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной.
* *
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op. **NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
* user_version как gate перестал работать — два модуля ставят его в 1,
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
*/ */
fun migrate(conn: SQLiteConnection) { fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN") conn.exec("BEGIN")
try { try {
if (current < 1) { conn.exec(v1ConversationDdl)
conn.exec(v1Ddl) conn.exec(v1MessageDdl)
conn.exec(v1IndexesDdl) conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT") conn.exec("COMMIT")
} catch (t: Throwable) { } catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") } runCatching { conn.exec("ROLLBACK") }
throw t 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")
}
} }
@@ -13,15 +13,9 @@ import kotlin.test.assertEquals
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Тесты для [KsqliteJournalStore] — точная копия * Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов * SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory * прогоняет `Schema.migrate` в init, явный вызов не нужен.
* SQLiteConnection + Schema.migrate).
*
* Тест `testClearRemovesByConversation` из оригинала использовал
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
* автономен и не знает про ConversationStore.
*/ */
class KsqliteJournalStoreTest { class KsqliteJournalStoreTest {
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}") conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteJournalStore(conn) store = KsqliteJournalStore(conn)
} }
@@ -103,4 +96,93 @@ class KsqliteJournalStoreTest {
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList()) assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size) assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
} }
@Test
fun testCountReturnsTotalForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
assertEquals(0L, store.count("conv1"))
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t, null))
store.append(MessageRecord.UserMessage("m3", "conv2", listOf(Content.Text("c")), t, null))
assertEquals(2L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("missing"))
}
@Test
fun testCountIsolatedFromOtherConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
// Bulk insert into conv1, single into conv2.
for (i in 1..50) {
store.append(MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t, null))
}
store.append(MessageRecord.UserMessage("n1", "conv2", listOf(Content.Text("only")), t, null))
assertEquals(50L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun testCountAfterFiltersStrictly() = 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))
// Strictly > — t1 is not counted when after=t1.
assertEquals(2L, store.count("conv1", t1))
// Strictly > — t2 IS counted (only t3 after).
assertEquals(1L, store.count("conv1", t2))
// After last — empty.
assertEquals(0L, store.count("conv1", t3))
// Distant past — all three.
assertEquals(3L, store.count("conv1", Instant.DISTANT_PAST))
}
@Test
fun testCountAfterIsolatesByConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), t, null))
// Cursor that excludes everything — both conversations read 0.
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("conv1", future))
assertEquals(0L, store.count("conv2", future))
// Cursor that includes everything — only conv1's message matches its conversation.
assertEquals(1L, store.count("conv1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("conv2", Instant.DISTANT_PAST))
}
@Test
fun testCountAgreesWithListSize() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..10) {
store.append(
MessageRecord.UserMessage(
id = "m$i",
conversationId = "conv1",
content = listOf(Content.Text("x$i")),
createdAt = t0 + kotlin.time.Duration.parse("PT${i}S"),
context = null,
),
)
}
// count === list(..., 0, +∞).size
assertEquals(
store.list("conv1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("conv1"),
)
// count(after=t5) === list(..., after=t5, 0, +∞).size
val cutoff = t0 + kotlin.time.Duration.parse("PT5S")
assertEquals(
store.list("conv1", cutoff, 0, 1000).size.toLong(),
store.count("conv1", cutoff),
)
}
} }
@@ -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()
}
}
}
@@ -1,26 +1,31 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest 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 pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* Тесты на Schema.migrate(): * Тесты на Schema.migrate() в `:journal-ksqlite`:
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION; * - fresh DB → создаются `conversation` + `message` + индексы
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на * (`idx_conv_updated`, `idx_msg_conv`);
* повторных CREATE); * - уже мигрированная БД → migrate() идемпотентен (no-op);
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores), * - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
* migrate() приводит её в боевое состояние. * migrate() приводит её в боевое состояние;
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
* делают full-scan по message.
* *
* Также проверяем что наличие индекса idx_msg_conv (conversation_id + * `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
* created_at) — обязательный hot-path для list()/cascade-delete. * в `pw.binom.agentik.reflection.ksqlite`.
*/ */
class SchemaMigrationTest { class SchemaMigrationTest {
@Test @Test
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest { fun `fresh DB gets conversation and message tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -28,8 +33,6 @@ class SchemaMigrationTest {
for (table in listOf( for (table in listOf(
Schema.TABLE_CONVERSATION, Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE, Schema.TABLE_MESSAGE,
Schema.TABLE_WORKING_MEMORY,
Schema.TABLE_REFLECTION,
)) { )) {
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()") assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
} }
@@ -37,15 +40,9 @@ class SchemaMigrationTest {
for (index in listOf( for (index in listOf(
Schema.IDX_CONV_UPDATED, Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_CONV, Schema.IDX_MSG_CONV,
Schema.IDX_WM_UNIQUE,
Schema.IDX_WM_CONV,
Schema.IDX_REFLECTION_CREATED,
Schema.IDX_REFLECTION_CONV,
)) { )) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()") assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
} }
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
} finally { } finally {
conn.close() conn.close()
} }
@@ -56,66 +53,50 @@ class SchemaMigrationTest {
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
val versionAfterFirst = readUserVersion(conn) // повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
// повторный вызов не должен ни упасть, ни изменить версию, ни
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn) Schema.migrate(conn)
assertEquals(versionAfterFirst, readUserVersion(conn)) Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
} finally { } finally {
conn.close() conn.close()
} }
} }
@Test @Test
fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest { fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
// Имитируем сценарий: существующая БД без schema, открываем через
// ksqlite и прогоняем migrate руками (тот же путь, что в
// KsqliteStores.open, но без зависимости от фабрики).
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn) Schema.migrate(conn)
// Сборка bundle через internal-конструктор — KsqliteStores primary val convStore = KsqliteMutableConversationStore(conn)
// constructor internal, тест в том же модуле и может его звать. val msgStore = KsqliteJournalStore(conn)
val stores = KsqliteStores(
connection = conn,
conversations = KsqliteConversationStore(conn),
messages = KsqliteMessageStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
try { try {
// bundle работает end-to-end — conversation upsert + message append + convStore.upsert(
// list. Никаких "no such table" или подобного. ConversationRecord(
stores.conversations.upsert(
pw.binom.agentik.journal.ConversationRecord(
id = "c1", title = "t", isTemporal = false, id = "c1", title = "t", isTemporal = false,
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
) )
) )
stores.messages.append( msgStore.append(
pw.binom.agentik.journal.MessageRecord.UserMessage( MessageRecord.UserMessage(
id = "m1", conversationId = "c1", id = "m1", conversationId = "c1",
content = listOf(pw.binom.agentik.journal.Content.Text("hi")), content = listOf(Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
) )
) )
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10) val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals("m1", got[0].id) assertEquals("m1", got[0].id)
} finally { } finally {
// Закрываем store'ы → они закроют свои pre-prepared statements convStore.close()
// (StmtHolder.finalize увидит isOpen == false и не полезет в msgStore.close()
// нативный sqlite3_finalize с уже-разрушенным db mutex).
stores.close()
} }
} }
@Test @Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest { fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
// Проверяем что индекс действительно покрывает обе колонки — без
// этого list()/cascade-delete будут делать full-scan по message.
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -158,16 +139,4 @@ class SchemaMigrationTest {
} }
return cols return cols
} }
private fun readUserVersion(conn: SQLiteConnection): Int {
var version = 0
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) {
version = (rs.getLong(0) ?: 0L).toInt()
}
}
}
return version
}
} }
+16 -7
View File
@@ -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,6 +14,14 @@ kotlin {
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
@@ -24,16 +31,18 @@ kotlin {
api(project(":context-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)
} }
} }
} }
@@ -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,
) { ) {
/** /**
@@ -0,0 +1,127 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -0,0 +1,71 @@
package pw.binom.agentik.llm.tools
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import pw.binom.agentik.llm.tools.ReflectionParser
class ReflectionParserTest {
@Test
fun `parses clean JSON`() {
val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}"""
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(4, p.score)
assertEquals("ok", p.summary)
assertEquals(listOf("a", "b"), p.weakSpots)
}
@Test
fun `parses JSON wrapped in json fences`() {
val raw = "```json\n" +
"{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" +
"```"
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(3, p.score)
assertEquals(listOf<String>(), p.weakSpots)
}
@Test
fun `parses JSON with leading and trailing text`() {
val raw = "Вот мой ответ:\n" +
"{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" +
"Конец."
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(2, p.score)
assertEquals(listOf("путаю", "медленно"), p.weakSpots)
}
@Test
fun `accepts score as string`() {
val raw = """{"score": "5", "summary": "ok", "weakSpots": []}"""
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(5, p.score)
}
@Test
fun `returns null on missing score`() {
val raw = """{"summary": "x", "weakSpots": []}"""
assertNull(ReflectionParser.parse(raw))
}
@Test
fun `returns null on invalid JSON`() {
assertNull(ReflectionParser.parse("not even json"))
}
@Test
fun `handles escape sequences in weakSpots`() {
// raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате
val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}"""
val p = ReflectionParser.parse(raw)
assertNotNull(p)
// парсер снимает один escape: \\\\ → \\
assertEquals(listOf("path\\\\file"), p.weakSpots)
}
}
@@ -0,0 +1,186 @@
package pw.binom.agentik.llm.tools.memory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.llm.tools.FakeLiteLlm
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
import pw.binom.agentik.llm.tools.ReviewPrompts
class LlmMemoryReviewerTest {
/**
* Минимальный in-memory store для тестов — реализует [MemoryStore],
* хранит заметки в MutableList, поддерживает events flow.
*/
private class InMemoryStore : MemoryStore {
private val notes = mutableMapOf<String, MemoryNote>()
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
override suspend fun upsert(note: MemoryNote) {
notes[note.id] = note
_events.emit(MemoryStoreEvent.Upserted(note))
}
override suspend fun get(id: String): MemoryNote? = notes[id]
override suspend fun list(
category: MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = notes.values
.filter { category == null || it.category == category }
.filter { conversationId == null || it.conversationId == conversationId }
.sortedByDescending { it.lastUsedAt }
.drop(offset)
.take(limit)
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> = emptyList()
override suspend fun delete(id: String): Boolean = notes.remove(id) != null
override suspend fun markUsed(id: String, at: Instant) {
notes[id]?.let {
notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1)
}
}
override fun events(): kotlinx.coroutines.flow.Flow<MemoryStoreEvent> = _events
override fun close() {}
// Helper for tests to seed notes
fun seed(note: MemoryNote) {
notes[note.id] = note
}
}
@Test
fun `review parses save JSON and applies upsert`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}"""
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(
ReviewedTurn(
userMessage = "Меня Саша зовут",
assistantMessage = "Приятно познакомиться, Саша!",
)
)
assertEquals(1, decision.toSave.size)
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(1, applied.saved)
val all = store.list()
assertEquals(1, all.size)
assertEquals("Имя — Саша", all[0].content)
assertEquals(MemorySource.AUTO_REVIEW, all[0].source)
}
@Test
fun `review applies delete decisions`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[],"delete":["mem-stale-1"]}"""
}
val store = InMemoryStore().apply {
seed(
MemoryNote(
id = "mem-stale-1",
category = MemoryCategory.USER,
content = "stale",
createdAt = Instant.parse("2026-01-01T00:00:00Z"),
lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"),
useCount = 0,
source = MemorySource.AUTO_REVIEW,
)
)
}
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("удали это", "ок"))
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(0, applied.saved)
assertEquals(1, applied.deleted)
assertEquals(0, store.list().size)
}
@Test
fun `review returns empty decision when LLM produces garbage`() = runTest {
val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." }
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
assertTrue(decision.toSave.isEmpty())
assertTrue(decision.toDelete.isEmpty())
}
@Test
fun `review handles empty LLM reply`() = runTest {
val llm = FakeLiteLlm().apply { reply = "" }
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
assertTrue(decision.toSave.isEmpty())
}
@Test
fun `review creates conversation with review system prompt`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[],"delete":[]}"""
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
reviewer.review(ReviewedTurn("u", "a"))
assertNotNull(llm.lastConfig)
assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction)
}
@Test
fun `reviewPreCompaction processes batch of turns`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """
{"save":[
{"category":"USER","content":"Работает в Яндексе"},
{"category":"WORLD","content":"JVector — pure-Java ANN"}
],"delete":[]}
""".trimIndent()
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val turns = listOf(
pw.binom.agentik.memory.ConversationTurn(
userMessage = "Я в Яндексе работаю",
assistantMessage = "Круто!",
),
pw.binom.agentik.memory.ConversationTurn(
userMessage = "А что за JVector?",
assistantMessage = "ANN-библиотека на Java.",
),
)
val decision = reviewer.reviewPreCompaction(turns)
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(2, applied.saved)
assertEquals(2, store.list().size)
}
}
@@ -0,0 +1,107 @@
package pw.binom.agentik.llm.tools.memory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.ReviewDecisionParser
class ReviewDecisionParserTest {
@Test
fun `parses save array with USER category`() {
val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}"""
val decision = ReviewDecisionParser.parse(raw)
assertEquals(1, decision.toSave.size)
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
assertEquals("Имя пользователя — Саша", decision.toSave[0].content)
assertTrue(decision.toDelete.isEmpty())
}
@Test
fun `parses all three categories`() {
val raw = """
{"save":[
{"category":"USER","content":"Работает в Яндексе"},
{"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"},
{"category":"PREFERENCE","content":"Отвечать кратко"}
],"delete":[]}
""".trimIndent()
val decision = ReviewDecisionParser.parse(raw)
assertEquals(3, decision.toSave.size)
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
assertEquals(MemoryCategory.WORLD, decision.toSave[1].category)
assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category)
}
@Test
fun `parses delete array with ids`() {
val raw = """{"save":[],"delete":["mem-123","mem-456"]}"""
val decision = ReviewDecisionParser.parse(raw)
assertTrue(decision.toSave.isEmpty())
assertEquals(listOf("mem-123", "mem-456"), decision.toDelete)
}
@Test
fun `returns empty decision on empty input`() {
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(""))
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" "))
}
@Test
fun `returns empty decision on non-JSON garbage`() {
val raw = "Извини, я не могу помочь с этим."
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
}
@Test
fun `returns empty decision on malformed JSON`() {
val raw = """{"save":[{"category":"USER","content":"foo""" // truncated
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
}
@Test
fun `extracts JSON from markdown code block`() {
val raw = """
Вот JSON:
```json
{"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]}
```
""".trimIndent()
val decision = ReviewDecisionParser.parse(raw)
assertEquals(1, decision.toSave.size)
assertEquals(MemoryCategory.WORLD, decision.toSave[0].category)
assertEquals("SQLite 3.51", decision.toSave[0].content)
}
@Test
fun `skips entries with unknown category`() {
val raw = """{"save":[
{"category":"USER","content":"valid"},
{"category":"NOT_A_CATEGORY","content":"should be skipped"},
{"category":"WORLD","content":"valid too"}
],"delete":[]}"""
val decision = ReviewDecisionParser.parse(raw)
assertEquals(2, decision.toSave.size)
assertEquals("valid", decision.toSave[0].content)
assertEquals("valid too", decision.toSave[1].content)
}
@Test
fun `skips entries with blank content`() {
val raw = """{"save":[
{"category":"USER","content":""},
{"category":"WORLD","content":" "}
],"delete":[]}"""
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
}
@Test
fun `handles escaped quotes in content`() {
val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}"""
val decision = ReviewDecisionParser.parse(raw)
assertEquals(1, decision.toSave.size)
assertEquals("Сказал \"привет\"", decision.toSave[0].content)
}
}
+56
View File
@@ -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) — пока нет.
+5 -3
View File
@@ -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()
+7
View File
@@ -21,6 +21,13 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
// `TextEmbeddingExecutor` (suspend-обёртка над `TextEmbeddingExtractor`)
// живёт в :memory-api с 2026-09-21 — раньше был `EmbeddingProvider` в
// :memory-vector, но он JVM-only и блокировал :memory-md-vector от
// нативных таргетов. text-embedding-kmp:api собирается под jvm+android+
// linux/macos/ios/mingw (мы добавили нативные цели в их :api модуле),
// так что KMP-потребители могут зависеть от него напрямую.
api(libs.text.embedding.api)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
@@ -24,4 +24,11 @@ data class MemoryNote(
val useCount: Int = 0, val useCount: Int = 0,
val conversationId: String? = null, val conversationId: String? = null,
val source: MemorySource, val source: MemorySource,
) ) {
/**
* Дешёвый content-fingerprint: хэш от id + content.
* Используется vector-кэшами (`:memory-md-vector`, `:memory-vector`) для
* определения "изменилась ли заметка" без re-embed'а.
*/
fun contentHash(): String = (id.hashCode().toLong() xor content.hashCode().toLong()).toString(16)
}
@@ -0,0 +1,58 @@
package pw.binom.agentik.memory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
/**
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score
* в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
* `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит
* от JVM-only `:memory-vector`.
*/
data class ScoredVector(
val id: String,
val score: Float,
)
/**
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
* векторов к query. Метаданные заметок лежат в `MemoryStore` (для
* vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`);
* индекс хранит только embedding'и + id-маппинг.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
* `:memory-md-vector` (KMP).
*
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации —
* это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
*/
interface MemoryVectorIndex : AutoCloseable {
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
val dimension: Int
/** Количество записей в индексе. */
suspend fun size(): Long
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
suspend fun add(id: String, embedding: FloatArray)
/** Удалить запись по [id]. Возвращает true если запись была. */
suspend fun remove(id: String): Boolean
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id. */
suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean = { true },
): List<ScoredVector>
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
suspend fun flush()
override fun close()
}
@@ -0,0 +1,22 @@
package pw.binom.agentik.memory
/**
* Доп. контекст для vector-индекса: фильтр по категории и conversationId.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с
* переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён
* сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`.
*
* Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые
* будущие) должны использовать этот хелпер при фильтрации результатов search,
* чтобы контракт был единый.
*/
fun noteMatches(
note: MemoryNote,
category: MemoryCategory? = null,
conversationId: String? = null,
): Boolean {
if (category != null && note.category != category) return false
if (conversationId != null && note.conversationId != conversationId) return false
return true
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.memory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/**
* Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`.
*
* `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP),
* поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают
* честный suspend, а блокирующая работа уходит в background dispatcher.
*
* Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI
* text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем
* его; иначе — определяем лениво по первому [embed] (probe-vector на пустом
* тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования,
* так что для prod-использования рекомендуется всегда передавать [knownDimension]
* явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а).
*
* @param extractor underlying extractor (не null)
* @param knownDimension заранее известная размерность; null = определить по probe
*/
class TextEmbeddingExecutor(
val extractor: TextEmbeddingExtractor,
val knownDimension: Int? = null,
) : AutoCloseable {
/** Размерность векторов. Эффективно константа после первого обращения. */
val dimension: Int by lazy {
knownDimension ?: extractor.embed("").dim
}
/**
* Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит
* в [Dispatchers.Default] — caller может безопасно await'ить.
*/
suspend fun embed(text: String): FloatArray =
withContext(Dispatchers.Default) { extractor.embed(text).values }
/** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
texts.map { embed(it) }
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
override fun close() {
extractor.close()
}
}
+51
View File
@@ -0,0 +1,51 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// :memory-md-vector — гибридное хранилище памяти:
//
// .md файлы (:memory-md, single source of truth)
// ↓ reconcile() на старте
// sqlite vector index (ksqlite + sqlite-vec vec0, derived cache)
//
// `.md` — единственный источник правды по метаданным и тексту заметок.
// Вектора — derived cache, перестраивается на старте и при `upsert`/`delete`.
//
// ANN-поиск: vector KNN (sqlite-vec MATCH) → top-50 → keyword rerank
// через `MdMemoryFormat.keywordScore` (vector 0.7 + keyword 0.3).
//
// Цели сборки — KMP: jvm() + linuxX64() + mingwX64(). До 2026-09-21 был
// JVM-only, потому что тащил `EmbeddingProvider` из JVM-only `:memory-vector`.
// С переходом на `TextEmbeddingExecutor` (из `:memory-api`, который тянет
// `pw.binom.ai.embeddingtext:api` — теперь KMP) модуль стал платформо-
// независимым. Под нативом тесты работают с `FakeTextEmbeddingExtractor`;
// прод-реализация (`:siglip` модуль text-embedding-kmp) пока JVM+Android only.
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.io.core)
api(project(":memory-api"))
implementation(project(":memory-md"))
// `text-embedding-api` тянется транзитивно через `:memory-api`
// (мы добавили `api(libs.text.embedding.api)` в memory-api/build.gradle.kts).
// Раньше тут стоял `implementation(project(":memory-vector"))` ради
// `EmbeddingProvider` — JVM-only модуль с JVector. Теперь не нужен.
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,206 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.md.MdMemoryFormat
import pw.binom.agentik.memory.md.MdMemoryStore
import pw.binom.agentik.memory.noteMatches
import kotlin.time.Instant
import kotlinx.coroutines.flow.Flow
/**
* Гибридное хранилище памяти:
*
* * `.md` файлы (через [MdMemoryStore]) — single source of truth по
* метаданным и тексту заметок;
* * ksqlite vector index ([KsqliteVectorIndex]) — derived cache embeddings
* и content_hash.
*
** Архитектурный контракт:
*
* 1. Любая мутация (upsert/delete) обновляет оба слоя атомарно: сначала
* `.md` (через [MdMemoryStore]), потом векторный кэш. Если vector-write
* упал — `.md` уже сохранён; reconcile при следующем старте восстановит
* консистентность.
*
* 2. [search] использует vector ANN (sqlite-vec MATCH) → top-50 → keyword
* rerank (`MdMemoryFormat.keywordScore`). Финальный score = 0.7 * vector
* + 0.3 * keyword. Это даёт семантический recall с быстрой фильтрацией
* по точным совпадениям.
*
* 3. [reconcile] — вызывается при старте (из [openHybridMemoryStore]):
* - .md файл есть, вектора нет → embed + add;
* - .md файл есть, вектор есть, content_hash отличается → re-embed;
* - .md файла нет, вектор есть → orphan, remove.
*
* 4. Read-only методы ([get], [list], [markUsed], [archiveStale], [events])
* делегируются в [MdMemoryStore] напрямую — никакой транзакции с
* vector-кэшем.
*
* Потокобезопасность: делегирующие методы — thread-safe за счёт
* `MdMemoryStore.mu`. Мутации векторов сериализуются
* [KsqliteVectorIndex.mutex]. Метод [reconcile] держит свой [mutex] для
* исключения конкурентных upsert'ов во время согласования.
*/
class HybridMdVectorStore internal constructor(
private val mdStore: MdMemoryStore,
private val vectorIndex: KsqliteVectorIndex,
private val embedder: TextEmbeddingExecutor,
) : MemoryStore {
private val reconcileMutex = Mutex()
/**
* Отчёт о согласовании `.md` ↔ vector-индекс. Возвращается из [reconcile].
*/
data class ReconcileReport(
val added: Int,
val reembedded: Int,
val orphansRemoved: Int,
) {
val totalChanged: Int get() = added + reembedded + orphansRemoved
}
/**
* Согласовать vector-кэш с текущим состоянием `.md` файлов.
*
* Идемпотентен — повторный вызов no-op.
*
* Можно вызывать из фонового потока при старте `Main.kt` чтобы
* залогировать "reconciled: 5 re-embedded, 2 added, 0 orphans".
*/
suspend fun reconcile(): ReconcileReport = reconcileMutex.withLock {
val onDisk: List<MemoryNote> = mdStore.list(limit = Int.MAX_VALUE)
val onDiskById: Map<String, MemoryNote> = onDisk.associateBy { it.id }
val inCache: List<KsqliteVectorIndex.MetaEntry> = vectorIndex.allMeta()
val cachedIds: Set<String> = inCache.map { it.id }.toSet()
var added = 0
var reembedded = 0
var orphansRemoved = 0
// 1) orphan-cleanup: vector есть, .md нет
for (cached in inCache) {
if (cached.id !in onDiskById) {
vectorIndex.remove(cached.id)
orphansRemoved++
}
}
// 2) re-embed / add
for (note in onDisk) {
val cached = inCache.firstOrNull { it.id == note.id }
val currentHash = note.contentHash()
if (cached == null) {
// .md есть, вектора нет → add
val vec = embedder.embed(note.content)
vectorIndex.add(note.id, vec, currentHash)
added++
} else if (cached.contentHash != currentHash) {
// .md изменился → re-embed
val vec = embedder.embed(note.content)
vectorIndex.add(note.id, vec, currentHash)
reembedded++
}
// else: cached.contentHash == currentHash → no-op
}
ReconcileReport(added, reembedded, orphansRemoved)
}
// ─── MemoryStore impl: мутации ─────────────────────────────────────
override suspend fun upsert(note: MemoryNote) {
mdStore.upsert(note)
val vec = embedder.embed(note.content)
vectorIndex.add(note.id, vec, note.contentHash())
}
override suspend fun delete(id: String): Boolean {
val existed = mdStore.delete(id)
vectorIndex.remove(id)
return existed
}
// ─── MemoryStore impl: search (hybrid) ────────────────────────────
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
if (query.query.isBlank()) return emptyList()
if (query.topK <= 0) return emptyList()
// Этап 1: vector ANN top-K (K=50 или больше topK).
val candidateK = maxOf(query.topK, VECTOR_CANDIDATES)
val qVec = embedder.embed(query.query)
val vectorHits = vectorIndex.search(qVec, candidateK)
// Этап 2: загружаем кандидатов из .md (single source of truth)
// mapNotNull не умеет suspend, поэтому собираем вручную.
val candidates: List<Pair<MemoryNote, Float>> = buildList(vectorHits.size) {
for (hit in vectorHits) {
val note = mdStore.get(hit.id) ?: continue
// Применяем категорийный/конво-фильтр ДО rerank — экономим keywordScore.
if (!noteMatches(note, query.category, query.conversationId)) continue
add(note to hit.score)
}
}
// Этап 3: keyword rerank (vector 0.7 + keyword 0.3)
val rescored = candidates.map { (note, vecScore) ->
val kwScore = MdMemoryFormat.keywordScore(query.query, note)
val finalScore = vecScore * VECTOR_WEIGHT + kwScore * KEYWORD_WEIGHT
MemorySearchResult(note, finalScore)
}.sortedByDescending { it.score }
return if (rescored.size > query.topK) rescored.subList(0, query.topK) else rescored
}
// ─── MemoryStore impl: read-only delegation ───────────────────────
override suspend fun get(id: String): MemoryNote? = mdStore.get(id)
override suspend fun list(
category: pw.binom.agentik.memory.MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = mdStore.list(category, conversationId, limit, offset)
override suspend fun markUsed(id: String, at: Instant) = mdStore.markUsed(id, at)
override suspend fun archiveStale(
maxAge: kotlin.time.Duration,
maxUseCount: Int,
now: Instant,
): Int {
// Вектор-кэш не хранит lastUsedAt/useCount (только content_hash).
// Делегируем в mdStore — он сам знает что удалять; vector удалится
// каскадно при archiveStale → delete loop ниже.
val deleted = mdStore.archiveStale(maxAge, maxUseCount, now)
// Дополнительно чистим vector-кэш от записей, которых больше нет в .md
val remaining = mdStore.list(limit = Int.MAX_VALUE).map { it.id }.toSet()
vectorIndex.allMeta().forEach { entry ->
if (entry.id !in remaining) vectorIndex.remove(entry.id)
}
return deleted
}
override fun events(): Flow<MemoryStoreEvent> = mdStore.events()
override fun close() {
runCatching { vectorIndex.close() }
runCatching { mdStore.close() }
}
companion object {
const val VECTOR_WEIGHT: Float = 0.7f
const val KEYWORD_WEIGHT: Float = 0.3f
const val VECTOR_CANDIDATES: Int = 50
}
}
@@ -0,0 +1,66 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.io.files.Path
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.md.openMdMemory
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Открыть гибридное хранилище памяти (`.md` + sqlite vector index).
*
* Создаёт:
* - [MdMemoryStore] на [memoryRoot] (`.md` файлы);
* - [KsqliteVectorIndex] на [vectorDbPath] (sqlite-vec vec0);
* - [HybridMdVectorStore] — обёртка с reconcile и hybrid search.
*
* Перед возвратом выполняет [HybridMdVectorStore.reconcile] — для свежей
* БД это приведёт к первичному embed'у всех `.md` файлов; для существующей —
* к re-embed'у изменившихся заметок и orphan-cleanup.
*
* @param memoryRoot директория с `.md` файлами (`USER.md`, `WORLD.md`, ...).
* @param vectorDbPath путь к файлу sqlite-БД для vector-кэша.
* @param dimension размерность embeddings от [embedder]. Фиксируется
* при создании индекса; дальнейшая смена = wipe БД.
* @param embedder провайдер embeddings.
* @param runReconcile выполнить [HybridMdVectorStore.reconcile] сразу после
* открытия. В тестах можно отключить для скорости.
*/
fun openHybridMemoryStore(
memoryRoot: Path,
vectorDbPath: Path,
dimension: Int,
embedder: TextEmbeddingExecutor,
runReconcile: Boolean = true,
): HybridMdVectorStore {
val md = openMdMemory(memoryRoot)
val conn = SQLiteConnection.open(vectorDbPath.toString())
Schema.migrate(conn, dimension)
val idx = KsqliteVectorIndex(conn, dimension)
val hybrid = HybridMdVectorStore(md, idx, embedder)
if (runReconcile) {
kotlinx.coroutines.runBlocking { hybrid.reconcile() }
}
return hybrid
}
/**
* In-memory вариант для тестов: vector-кэш в `:memory:` sqlite,
* `.md` — в `/tmp/agentik-hybrid-test-{random}`.
*
* Используется POSIX-путь `/tmp`, потому что [System.getenv] / [System.getProperty]
* недоступны в KMP commonMain (только JVM). На Windows mingwX64 этот вызов
* упадёт — там тесты пока не предполагаются, нативные тесты только linuxX64.
* Под JVM `/tmp` либо есть как symlink (Linux/macOS), либо стоит использовать
* jvmTest-специфичный factory.
*/
fun openInMemoryHybridMemoryStore(
dimension: Int,
embedder: TextEmbeddingExecutor,
): HybridMdVectorStore {
val tmpDir = Path("/tmp/agentik-hybrid-test-${kotlin.random.Random.nextLong()}")
val md = openMdMemory(tmpDir)
val conn = SQLiteConnection.memory("hybrid-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn, dimension)
val idx = KsqliteVectorIndex(conn, dimension)
return HybridMdVectorStore(md, idx, embedder)
}
@@ -0,0 +1,47 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.io.files.Path
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.md.KeywordMdPrefetcher
import pw.binom.agentik.memory.md.KeywordMdReviewer
/**
* Связка [HybridMdVectorStore] + keyword-prefetcher + keyword-reviewer.
*
* Store делегирует I/O между .md (single source of truth) и sqlite-vector-кэшем;
* prefetcher и reviewer работают по .md-данным (через [HybridMdVectorStore]),
* так что обе роли видят консистентное состояние.
*/
class HybridMemorySystem internal constructor(
override val store: MemoryStore,
override val prefetcher: MemoryPrefetcher,
override val reviewer: MemoryReviewer,
) : MemorySystem {
override fun close() = store.close()
}
/**
* Собирает [HybridMemorySystem] для указанной корневой директории + sqlite-БД.
*
* Под капотом: [HybridMdVectorStore] (md + vector), keyword-prefetcher из
* `:memory-md` (работает по store.api), keyword-reviewer без LLM —
* LLM-импл добавляется в `:standalone` поверх.
*/
fun openHybridMemorySystem(
memoryRoot: Path,
vectorDbPath: Path,
dimension: Int,
embedder: TextEmbeddingExecutor,
runReconcile: Boolean = true,
): HybridMemorySystem {
val store = openHybridMemoryStore(memoryRoot, vectorDbPath, dimension, embedder, runReconcile)
return HybridMemorySystem(
store = store,
prefetcher = KeywordMdPrefetcher(store),
reviewer = KeywordMdReviewer(),
)
}
@@ -0,0 +1,305 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryVectorIndex
import pw.binom.agentik.memory.ScoredVector
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
/**
* ksqlite-реализация [MemoryVectorIndex] поверх `vec0` virtual table
* (sqlite-vec extension, встроен в ksqlite).
*
* Маппинг id → rowid:
* - TEXT `id` (== MemoryNote.id, "mem-...") лежит в [Schema.TABLE_META].
* - `vec0` индексирует по `rowid` (INTEGER auto-increment).
* - JOIN через `WHERE vec0.rowid = meta.rowid`.
*
* На каждое [add] с contentHash рядом с вектором пишется meta с
* content_hash от [MemoryNote.contentHash]. Это позволяет reconcile'у
* в [HybridMdVectorStore] определить "изменилась ли заметка" без re-embed.
*
* Поиск — `vec0` MATCH (cosine distance, sqlite-vec native). Score
* конвертируется из distance (0..2, меньше = ближе) в similarity
* (0..1, больше = ближе).
*
* Конкурентность: write-операции сериализуются [mutex]; read'ы
* (`size`/`search`) не блокируют.
*/
class KsqliteVectorIndex internal constructor(
private val conn: SQLiteConnection,
override val dimension: Int,
) : MemoryVectorIndex {
private val mutex = Mutex()
private val insertVec: SQLitePreparedStatement = conn.prepare(
"INSERT INTO ${Schema.TABLE_VECTORS}(${Schema.COL_VECTOR}) VALUES (?)"
)
private val lastInsertRowIdStmt: SQLitePreparedStatement = conn.prepare(
"SELECT last_insert_rowid()"
)
private val insertMeta: SQLitePreparedStatement = conn.prepare(
"""
INSERT OR REPLACE INTO ${Schema.TABLE_META}
(${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH},
${Schema.COL_DIMENSION}, ${Schema.COL_UPDATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val findMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
)
/** Запрос `... WHERE rowid IN (?, ?, ...)`. Подготавливаем на N=$MAX_INLINE_ROWIDS параметров. */
private val findMetaByRowIdsStmt: SQLitePreparedStatement = conn.prepare(
(1..MAX_INLINE_ROWIDS).joinToString(
separator = ",",
prefix = "SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} IN (",
postfix = ")",
) { "?" }
)
private val deleteByRowIdStmt: SQLitePreparedStatement = conn.prepare(
"DELETE FROM ${Schema.TABLE_VECTORS} WHERE rowid = ?"
)
private val deleteMetaByRowIdStmt: SQLitePreparedStatement = conn.prepare(
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} = ?"
)
private val deleteMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
)
private val sizeMetaStmt: SQLitePreparedStatement = conn.prepare(
"SELECT COUNT(*) FROM ${Schema.TABLE_META}"
)
private val allMetaStmt: SQLitePreparedStatement = conn.prepare(
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META}"
)
/**
* sqlite-vec требует чтобы `LIMIT` в MATCH-запросе был integer-литералом,
* а не `?`. Поэтому для search используем динамическую подготовку
* (кешированную по [k]).
*
* Vec0 KNN check (`sqlite-vec` source): «A LIMIT or 'k = ?' constraint is
* required on vec0 knn queries». Имя параметра `:k` тоже поддерживается,
* но у ksqlite bind API — только позиционный; literal проще.
*/
private val searchCache = HashMap<Int, SQLitePreparedStatement>()
/**
* Выдать rowid для существующей записи или -1 если нет.
*
* **ВАЖНО**: вызывающий ОБЯЗАН держать [mutex]. Этот метод НЕ
* reentrant — повторный вход в [Mutex.withLock] приведёт к
* deadlock (kotlinx.coroutines.sync.Mutex не reentrant).
*/
private fun findRowIdLocked(id: String): Long {
findMetaByIdStmt.reset()
findMetaByIdStmt.clearBindings()
findMetaByIdStmt.bindText(1, id)
findMetaByIdStmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0) ?: -1L
}
return -1L
}
override suspend fun size(): Long = mutex.withLock {
sizeMetaStmt.reset()
sizeMetaStmt.clearBindings()
sizeMetaStmt.executeQuery().use { rs ->
if (rs.next()) rs.getLong(0) ?: 0L else 0L
}
}
override suspend fun add(id: String, embedding: FloatArray) {
require(embedding.size == dimension) {
"embedding dim=${embedding.size} != index dim=$dimension"
}
add(id, embedding, contentHash = "")
}
/**
* Добавить или обновить запись с явным content_hash.
* Если запись с таким id уже есть — обновляет и вектор, и meta.
* Иначе — создаёт новый rowid.
*/
suspend fun add(id: String, embedding: FloatArray, contentHash: String) {
require(embedding.size == dimension) {
"embedding dim=${embedding.size} != index dim=$dimension"
}
mutex.withLock {
val existingRowId = findRowIdLocked(id)
val rowId: Long = if (existingRowId > 0) {
// Update: заменяем вектор по существующему rowid
insertVec.reset()
insertVec.clearBindings()
insertVec.bindVector(1, embedding)
insertVec.executeUpdate()
existingRowId
} else {
// Insert: получаем свежий rowid
insertVec.reset()
insertVec.clearBindings()
insertVec.bindVector(1, embedding)
insertVec.executeUpdate()
lastInsertRowIdStmt.reset()
lastInsertRowIdStmt.clearBindings()
lastInsertRowIdStmt.executeQuery().use { rs ->
if (rs.next()) rs.getLong(0) ?: error("no last_insert_rowid()") else error("no last_insert_rowid()")
}
}
insertMeta.reset()
insertMeta.clearBindings()
insertMeta.bindLong(1, rowId)
insertMeta.bindText(2, id)
insertMeta.bindText(3, contentHash)
insertMeta.bindLong(4, dimension.toLong())
insertMeta.bindLong(5, Clock.System.now().toEpochMilliseconds())
insertMeta.executeUpdate()
}
}
override suspend fun remove(id: String): Boolean = mutex.withLock {
val rowId = findRowIdLocked(id)
if (rowId <= 0) return@withLock false
// Удаляем meta сначала — иначе orphan-row в vec0.
deleteMetaByIdStmt.reset()
deleteMetaByIdStmt.clearBindings()
deleteMetaByIdStmt.bindText(1, id)
deleteMetaByIdStmt.executeUpdate()
deleteByRowIdStmt.reset()
deleteByRowIdStmt.clearBindings()
deleteByRowIdStmt.bindLong(1, rowId)
deleteByRowIdStmt.executeUpdate()
true
}
/** Прочитать content_hash для id. null если записи нет. */
suspend fun getContentHash(id: String): String? = mutex.withLock {
findMetaByIdStmt.reset()
findMetaByIdStmt.clearBindings()
findMetaByIdStmt.bindText(1, id)
findMetaByIdStmt.executeQuery().use { rs ->
if (rs.next()) rs.getText(1) else null
}
}
/** Полный список (rowid, id, contentHash) для reconcile'а. */
suspend fun allMeta(): List<MetaEntry> = mutex.withLock {
allMetaStmt.reset()
allMetaStmt.clearBindings()
val out = mutableListOf<MetaEntry>()
allMetaStmt.executeQuery().use { rs ->
while (rs.next()) {
val rowId = rs.getLong(0) ?: continue
val id = rs.getText(1) ?: continue
val hash = rs.getText(2) ?: continue
out.add(MetaEntry(rowId, id, hash))
}
}
out
}
override suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean,
): List<ScoredVector> {
require(query.size == dimension) {
"query dim=${query.size} != index dim=$dimension"
}
val safeK = k.coerceAtLeast(1)
return mutex.withLock {
// sqlite-vec MATCH требует минимальный запрос без JOIN/лишних
// ORDER BY — иначе "A LIMIT or 'k = ?' constraint is required".
// Поэтому делаем два запроса:
// 1) vec0 ANN → (rowid, distance)
// 2) meta lookup по собранным rowid → id
val stmt = searchCache.getOrPut(safeK) {
conn.prepare(
"""
SELECT rowid, distance
FROM ${Schema.TABLE_VECTORS}
WHERE ${Schema.COL_VECTOR} MATCH ?
ORDER BY distance
LIMIT $safeK
""".trimIndent()
)
}
stmt.reset()
stmt.clearBindings()
stmt.bindVector(1, query)
val candidates = mutableListOf<Pair<Long, Float>>()
stmt.executeQuery().use { rs ->
while (rs.next()) {
val rowId = rs.getLong(0) ?: continue
val distance = rs.getDouble(1) ?: continue
val score = ((1.0 - distance / 2.0) * 1.0).toFloat().coerceIn(0f, 1f)
candidates.add(rowId to score)
}
}
if (candidates.isEmpty()) return@withLock emptyList<ScoredVector>()
// 2-й запрос: meta по списку rowid.
val rowIds = candidates.map { it.first }
val idByRowId = HashMap<Long, String>(candidates.size)
findMetaByRowIdsStmt.reset()
findMetaByRowIdsStmt.clearBindings()
for ((idx, rowId) in rowIds.withIndex()) {
findMetaByRowIdsStmt.bindLong(idx + 1, rowId)
}
findMetaByRowIdsStmt.executeQuery().use { rs ->
while (rs.next()) {
val rowId = rs.getLong(0) ?: continue
val id = rs.getText(1) ?: continue
idByRowId[rowId] = id
}
}
candidates.mapNotNull { (rowId, score) ->
idByRowId[rowId]?.let { ScoredVector(it, score) }
}
}
}
override suspend fun flush() {
// ksqlite + WAL — flush не нужен. Метод для совместимости с интерфейсом.
}
override fun close() {
insertVec.close()
lastInsertRowIdStmt.close()
insertMeta.close()
findMetaByIdStmt.close()
deleteByRowIdStmt.close()
deleteMetaByRowIdStmt.close()
deleteMetaByIdStmt.close()
sizeMetaStmt.close()
allMetaStmt.close()
searchCache.values.forEach { it.close() }
}
data class MetaEntry(val rowId: Long, val id: String, val contentHash: String)
private companion object {
/** Максимум rowid, которые мы зашиваем в `IN (?,?,...)` одним prepared statement'ом. */
const val MAX_INLINE_ROWIDS = 256
}
}
@@ -0,0 +1,80 @@
package pw.binom.agentik.memory.mdvector
import pw.binom.db.ksqlite.SQLiteConnection
/**
* DDL/DML для ksqlite-бэкенда `:memory-md-vector`.
*
* Две таблицы:
*
* * `memory_vectors` (vec0) — ANN-индекс. Содержит embedding + первичный
* ключ `rowid` (auto-increment INTEGER, sqlite-vec требует именно его).
* Размерность задаётся `float[DIMENSION]` при создании.
*
* * `memory_meta` — рядом с вектором: TEXT `id` (== MemoryNote.id) +
* `rowid` (тот же, что в vec0) + `content_hash` (от MemoryNote.contentHash()).
* Используется reconcile'ом — если хэш в meta не совпадает с тем, что
* вычисляется из текущего `.md` файла → re-embed.
*
* JOIN между vec0 и meta: `WHERE vec0.rowid = meta.rowid`.
*
* Миграция через `PRAGMA user_version` (как в `:journal-ksqlite/Schema.kt`).
*/
internal object Schema {
const val CURRENT_VERSION: Int = 1
const val TABLE_VECTORS = "memory_vectors"
const val TABLE_META = "memory_meta"
const val COL_ID = "id"
const val COL_ROWID = "rowid"
const val COL_VECTOR = "embedding"
const val COL_HASH = "content_hash"
const val COL_DIMENSION = "dimension"
const val COL_UPDATED_AT = "updated_at"
fun v1Ddl(dimension: Int): String = """
CREATE VIRTUAL TABLE IF NOT EXISTS $TABLE_VECTORS USING vec0(
$COL_VECTOR float[$dimension]
);
CREATE TABLE IF NOT EXISTS $TABLE_META (
$COL_ROWID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
$COL_ID TEXT NOT NULL UNIQUE,
$COL_HASH TEXT NOT NULL,
$COL_DIMENSION INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meta_id ON $TABLE_META($COL_ID);
""".trimIndent()
fun migrate(conn: SQLiteConnection, dimension: Int) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl(dimension))
}
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
conn.exec("PRAGMA user_version = $version")
}
}
@@ -0,0 +1,166 @@
package pw.binom.agentik.memory.mdvector
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking as kRunBlocking
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.voice.embeddingtext.TextEmbedding
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/**
* Тесты гибридного стора: делегирование в .md, vector ANN, reconcile,
* hybrid search rerank.
*
* Используется [kRunBlocking] (а не `runTest`) потому что `MdMemoryStore`
* делает реальный файловый I/O (`kotlinx-io`), который плохо дружит с
* TestDispatcher'ом — `runTest` зависает на virtual-time I/O.
*/
class HybridMdVectorStoreTest {
/**
* Детерминированный [TextEmbeddingExtractor] для тестов модуля.
* Хеширует текст в псевдо-вектор фиксированной размерности, L2-normalize.
* Заворачивается в [TextEmbeddingExecutor] с пред-объявленной размерностью.
*/
private fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor =
TextEmbeddingExecutor(FakeTestExtractor(dimension), knownDimension = dimension)
private class FakeTestExtractor(val dim: Int) : TextEmbeddingExtractor {
override fun embed(text: String): TextEmbedding {
val v = FloatArray(dim)
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
for (i in 0 until dim) {
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
var norm = 0f
for (x in v) norm += x * x
norm = kotlin.math.sqrt(norm)
if (norm > 0f) for (i in v.indices) v[i] /= norm
return TextEmbedding(v)
}
override fun close() = Unit
}
private fun note(
id: String,
content: String,
category: MemoryCategory = MemoryCategory.USER,
source: MemorySource = MemorySource.USER_EXPLICIT,
) = MemoryNote(
id = id,
category = category,
content = content,
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
lastUsedAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
useCount = 0,
conversationId = null,
source = source,
)
@Test
fun upsertWritesToBothMdAndVectorCache() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-1", "user prefers dark mode"))
val fromMd = store.get("mem-1")
assertNotNull(fromMd)
assertEquals("user prefers dark mode", fromMd.content)
val hits = store.search(MemorySearchQuery(query = "user prefers dark mode", topK = 5))
assertEquals(1, hits.size)
assertEquals("mem-1", hits.first().note.id)
store.close()
}
@Test
fun deleteRemovesFromBothLayers() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-2", "lives in Saint Petersburg"))
store.upsert(note("mem-3", "loves Kotlin multiplatform"))
assertEquals(2, store.list(limit = 10).size)
val removed = store.delete("mem-2")
assertTrue(removed)
assertEquals(1, store.list(limit = 10).size)
assertNull(store.get("mem-2"))
val hits = store.search(MemorySearchQuery(query = "Saint Petersburg", topK = 5))
assertTrue(hits.isEmpty() || hits.all { it.note.id != "mem-2" })
store.close()
}
@Test
fun reconcileOnEmptyStoreIsNoOp() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
val report = store.reconcile()
assertEquals(0, report.added)
assertEquals(0, report.reembedded)
assertEquals(0, report.orphansRemoved)
store.close()
}
@Test
fun reconcileIsIdempotent() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-10", "works at Binom"))
val report = store.reconcile()
assertEquals(0, report.totalChanged, "идемпотентность reconcile: повторный вызов no-op")
store.close()
}
@Test
fun searchReturnsRelevantResultsByKeyword() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-a", "kotlin multiplatform"))
store.upsert(note("mem-b", "java enterprise"))
store.upsert(note("mem-c", "kotlin coroutines"))
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 5))
val ids = hits.map { it.note.id }.toSet()
assertTrue("mem-a" in ids, "expected 'kotlin multiplatform' in results: $ids")
assertTrue("mem-c" in ids, "expected 'kotlin coroutines' in results: $ids")
store.close()
}
@Test
fun searchRespectsCategoryFilter() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("user-1", "kotlin lover", MemoryCategory.USER))
store.upsert(note("world-1", "kotlin 2.0 released", MemoryCategory.WORLD))
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 10, category = MemoryCategory.USER))
assertEquals(1, hits.size)
assertEquals("user-1", hits.first().note.id)
store.close()
}
@Test
fun hybridScoreCombinesVectorAndKeyword() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-x", "kotlin multiplatform project"))
val hits = store.search(MemorySearchQuery(query = "kotlin multiplatform", topK = 1))
assertEquals(1, hits.size)
val score = hits.first().score
assertTrue(score in 0f..1f, "score $score out of range")
assertTrue(score > 0.5f, "expected hybrid score > 0.5, got $score")
store.close()
}
}
+9 -7
View File
@@ -3,8 +3,9 @@ plugins {
} }
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp // CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не // не подключаются. Раньше был нужен потому что text-embedding-kmp был
// опубликован в caffeine, артефакты есть только в локальном ~/.m2. // только в локальном ~/.m2; с 2026-09-21 (v4 в caffeine) можно убрать,
// но оставлен на случай если CI внезапно отвалится от Nexus.
// Использование: // Использование:
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true // ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
// Локальная разработка без флага — зависимости подключаются как обычно. // Локальная разработка без флага — зависимости подключаются как обычно.
@@ -25,6 +26,10 @@ kotlin {
api(project(":memory-api")) api(project(":memory-api"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// `pw.binom.ai.embeddingtext:api` (TextEmbeddingExtractor + TextEmbedding)
// теперь KMP с нативом (linuxX64/mingwX64/macOS/ios); тянем в commonMain.
// Реализации (`siglip`, `http`) — JVM+Android only, см. jvmMain ниже.
api(libs.text.embedding.api)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
@@ -34,14 +39,11 @@ kotlin {
jvmMain.dependencies { jvmMain.dependencies {
implementation(libs.jvector) implementation(libs.jvector)
implementation(libs.sqldelight.sqlite.driver) implementation(libs.sqldelight.sqlite.driver)
// Конкретная реализация TextEmbeddingExtractor поверх ONNX.
implementation(libs.text.embedding.siglip)
} }
jvmTest.dependencies { jvmTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
} }
} }
} }
dependencies {
add("jvmMainApi", libs.text.embedding.api)
add("jvmMainImplementation", libs.text.embedding.siglip)
}
@@ -1,39 +0,0 @@
package pw.binom.agentik.memory.vector
/**
* Провайдер эмбеддингов: превращает текст в FloatArray фиксированной размерности.
*
* Реализация по умолчанию — HTTP-вызов `POST /v1/embeddings` к OpenAI-совместимому
* API (OpenAI / litellm-proxy / vllm). С LRU-кэшом, чтобы не ходить в сеть
* на каждый search/upsert.
*/
interface EmbeddingProvider {
val dimension: Int
suspend fun embed(text: String): FloatArray
/** Batch-вариант. По умолчанию — последовательный вызов [embed]. */
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
texts.map { embed(it) }
}
/**
* Детерминированный провайдер для тестов: хеширует текст в псевдо-вектор.
* Используется только в commonTest; в продакшн заменяется на HttpEmbeddingProvider.
*/
class FakeEmbeddingProvider(override val dimension: Int = 32) : EmbeddingProvider {
override suspend fun embed(text: String): FloatArray {
val v = FloatArray(dimension)
// Простейший детерминированный seed — сумма char'ов по модулю.
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
for (i in 0 until dimension) {
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
// L2-normalize чтобы cosine работал осмысленно.
var norm = 0f
for (x in v) norm += x * x
norm = kotlin.math.sqrt(norm)
if (norm > 0f) for (i in v.indices) v[i] /= norm
return v
}
}
@@ -1,63 +1,34 @@
package pw.binom.agentik.memory.vector package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryVectorIndex as KmpMemoryVectorIndex
import pw.binom.agentik.memory.MemoryNote import pw.binom.agentik.memory.ScoredVector as KmpScoredVector
/** /**
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1]. * JVM-only alias на KMP-контракт из `:memory-api`. Удалять нельзя — пока
* Чем ближе к 1.0, тем семантически ближе query к заметке. * `:memory-vector` существует как JVM-only модуль с JVector-имплементацией,
*/ * все его internal helper'ы продолжают импортировать `MemoryVectorIndex` из
data class ScoredVector( * `pw.binom.agentik.memory.vector.*` (старое FQN). После удаления модуля —
val id: String, * можно убрать этот файл и переименовать пакеты импортов.
val score: Float,
)
/**
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
* *
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных * Раньше жил прямо здесь (`MemoryVectorIndex` + `ScoredVector` в
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это * `:memory-vector/commonMain`), но переехал в `:memory-api` 2026-09-21
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций). * чтобы стать доступным из KMP-модуля `:memory-md-vector`.
*/ */
interface MemoryVectorIndex : AutoCloseable {
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
val dimension: Int
/** Количество записей в индексе. */ @Deprecated(
suspend fun size(): Long message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
replaceWith = ReplaceWith(
"MemoryVectorIndex",
"pw.binom.agentik.memory.MemoryVectorIndex",
),
)
typealias MemoryVectorIndex = KmpMemoryVectorIndex
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */ @Deprecated(
suspend fun add(id: String, embedding: FloatArray) message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
replaceWith = ReplaceWith(
/** Удалить запись по [id]. Возвращает true если запись была. */ "ScoredVector",
suspend fun remove(id: String): Boolean "pw.binom.agentik.memory.ScoredVector",
),
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id (например, по категории). */ )
suspend fun search( typealias ScoredVector = KmpScoredVector
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean = { true },
): List<ScoredVector>
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
suspend fun flush()
override fun close()
}
/**
* Доп. контекст для vector-индекса: фильтр по категории и conversationId
* передаётся через замыкание, которое получает [MemoryNote]. Так [MemoryStore]
* остаётся единственным источником правды по метаданным.
*/
fun noteMatches(
note: MemoryNote,
category: MemoryCategory? = null,
conversationId: String? = null,
): Boolean {
if (category != null && note.category != category) return false
if (conversationId != null && note.conversationId != conversationId) return false
return true
}
@@ -6,6 +6,8 @@ import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.noteMatches
import kotlin.math.exp import kotlin.math.exp
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -23,13 +25,13 @@ import kotlinx.coroutines.sync.withLock
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов, * и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
* потом re-rank по recency. * потом re-rank по recency.
* *
* [embeddingProvider] обязателен — используется для эмбеддинга контента при * [embedding] обязателен — используется для эмбеддинга контента при
* upsert и query при search. Без него vector-бэкенд не имеет смысла. * upsert и query при search. Без него vector-бэкенд не имеет смысла.
*/ */
class VectorMemoryStore( class VectorMemoryStore(
private val index: MemoryVectorIndex, private val index: MemoryVectorIndex,
private val metaStore: MemoryMetaStore, private val metaStore: MemoryMetaStore,
private val embeddingProvider: EmbeddingProvider, private val embedding: TextEmbeddingExecutor,
) : MemoryStore { ) : MemoryStore {
private val mutex = Mutex() private val mutex = Mutex()
@@ -37,9 +39,9 @@ class VectorMemoryStore(
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow() override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
override suspend fun upsert(note: MemoryNote) = mutex.withLock { override suspend fun upsert(note: MemoryNote) = mutex.withLock {
val embedding = embeddingProvider.embed(note.content) val vec = embedding.embed(note.content)
metaStore.put(note, embedding) metaStore.put(note, vec)
index.add(note.id, embedding) index.add(note.id, vec)
_events.emit(MemoryStoreEvent.Upserted(note)) _events.emit(MemoryStoreEvent.Upserted(note))
} }
@@ -53,7 +55,7 @@ class VectorMemoryStore(
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset) ): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> { override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
val queryEmbedding = embeddingProvider.embed(query.query) val queryEmbedding = embedding.embed(query.query)
val overFetch = (query.topK * 5).coerceAtLeast(query.topK) val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId // Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
// через [metaStore.get] + [noteMatches] отрежет лишних. // через [metaStore.get] + [noteMatches] отрежет лишних.
@@ -9,6 +9,7 @@ import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystem import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.memory.TextEmbeddingExecutor
/** /**
* Бандл компонентов vector-бэкенда памяти — то же, что * Бандл компонентов vector-бэкенда памяти — то же, что
@@ -36,19 +37,20 @@ class VectorMemorySystem(
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client. * Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
* *
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs) * @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient * @param embedding [TextEmbeddingExecutor] — обычно HttpEmbeddingClient.asExecutor()
* @param topK размер top-K для prefetch * @param topK размер top-K для prefetch
*/ */
fun open( fun open(
dbPath: String, dbPath: String,
embedding: EmbeddingProvider, embedding: TextEmbeddingExecutor,
topK: Int = 10, topK: Int = 10,
): VectorMemorySystem { ): VectorMemorySystem {
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension) val dim = embedding.dimension
val metaStore = SqliteMemoryMetaStore.open(dbPath, dim)
// Граф пересобирается из SQLite (источник правды): без seed'ов // Граф пересобирается из SQLite (источник правды): без seed'ов
// после рестарта in-RAM индекс пуст и search возвращал бы [], // после рестарта in-RAM индекс пуст и search возвращал бы [],
// пока не появятся новые upsert'ы. // пока не появятся новые upsert'ы.
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries()) val index = JVectorMemoryIndex(dim, metaStore.allEntries())
val store = VectorMemoryStore(index, metaStore, embedding) val store = VectorMemoryStore(index, metaStore, embedding)
val prefetcher = VectorPrefetcher(store, topK) val prefetcher = VectorPrefetcher(store, topK)
val reviewer = VectorMemoryReviewer(store) val reviewer = VectorMemoryReviewer(store)
@@ -59,7 +61,7 @@ class VectorMemorySystem(
closables = listOfNotNull( closables = listOfNotNull(
metaStore, metaStore,
index, index,
embedding as? AutoCloseable, embedding,
), ),
) )
} }
@@ -5,25 +5,26 @@ import java.net.http.HttpClient
import java.net.http.HttpRequest import java.net.http.HttpRequest
import java.net.http.HttpResponse import java.net.http.HttpResponse
import java.time.Duration import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import pw.binom.agentik.memory.vector.EmbeddingProvider import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.voice.embeddingtext.TextEmbedding
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/** /**
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint. * HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
* Используется при memory-backend=vector. * Используется при memory-backend=vector.
* *
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов * Реализует [TextEmbeddingExtractor] (из text-embedding-kmp:api) + оборачивается
* к API на одинаковых промптах. * в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
* VectorMemoryStore. LRU-кэш на [cacheSize] текстов (default 256) — дедупликация
* запросов к API на одинаковых промптах.
* *
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com` * @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
* @param apiKey bearer-токен * @param apiKey bearer-токен
@@ -35,9 +36,9 @@ class HttpEmbeddingClient(
private val apiUrl: String, private val apiUrl: String,
private val apiKey: String, private val apiKey: String,
private val model: String, private val model: String,
override val dimension: Int, private val dimension: Int,
cacheSize: Int = 256, cacheSize: Int = 256,
) : EmbeddingProvider, AutoCloseable { ) : TextEmbeddingExtractor {
private val cache = LruCache<String, FloatArray>(cacheSize) private val cache = LruCache<String, FloatArray>(cacheSize)
private val http: HttpClient = HttpClient.newBuilder() private val http: HttpClient = HttpClient.newBuilder()
@@ -45,11 +46,11 @@ class HttpEmbeddingClient(
.build() .build()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
override suspend fun embed(text: String): FloatArray { override fun embed(text: String): TextEmbedding {
cache.get(text)?.let { return it } cache.get(text)?.let { return TextEmbedding(it) }
val vector = fetchEmbedding(text) val vector = fetchEmbedding(text)
cache.put(text, vector) cache.put(text, vector)
return vector return TextEmbedding(vector)
} }
private fun fetchEmbedding(text: String): FloatArray { private fun fetchEmbedding(text: String): FloatArray {
@@ -83,6 +84,9 @@ class HttpEmbeddingClient(
} }
override fun close() = http.close() override fun close() = http.close()
/** Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью. */
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = dimension)
} }
private class LruCache<K, V>(private val capacity: Int) { private class LruCache<K, V>(private val capacity: Int) {
@@ -1,25 +1,22 @@
package pw.binom.agentik.memory.vector.embedding package pw.binom.agentik.memory.vector.embedding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import pw.binom.agentik.memory.TextEmbeddingExecutor
import kotlinx.coroutines.sync.withLock import pw.binom.voice.embeddingtext.TextEmbedding
import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.vector.EmbeddingProvider
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
/** /**
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX). * Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
* *
* Особенности: * Реализует [TextEmbeddingExtractor] напрямую (делегирует в
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU), * `createSiglip2TextExtractor` из text-embedding-kmp:siglip) + оборачивается
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать * в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
* доступ из нескольких корутин (ONNX-сессия не reentrant). * VectorMemoryStore. Сиглизация через `Dispatchers.IO` теперь внутри
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр * `TextEmbeddingExecutor.embed` — раньше лежала здесь.
* `dimension` в конструкторе не принимаем — берём через [probeDimension]. *
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на * Размерность фиксирована extractor'ом (SigLIP2-base = 768); передаём
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется — * явно в [asExecutor].
* легко добавить.
* *
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе. * Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
* Скачать: см. README репы `text-embedding-kmp`. * Скачать: см. README репы `text-embedding-kmp`.
@@ -27,23 +24,22 @@ import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
class SiglipEmbeddingProvider( class SiglipEmbeddingProvider(
modelPath: String, modelPath: String,
tokenizerPath: String, tokenizerPath: String,
) : EmbeddingProvider, AutoCloseable { ) : TextEmbeddingExtractor {
private val extractor: TextEmbeddingExtractor = private val delegate: TextEmbeddingExtractor =
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath) createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
override val dimension: Int = run { override fun embed(text: String): TextEmbedding = delegate.embed(text)
val probe = extractor.embed("probe")
probe.dim
}
private val mutex = Mutex() override fun close() = delegate.close()
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) { /**
mutex.withLock { extractor.embed(text).values } * Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью 768
} * (SigLIP2-base). Сигнатура стабильна — extractor всегда возвращает 768-dim.
*/
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = SIGLIP2_DIM)
override fun close() { companion object {
extractor.close() const val SIGLIP2_DIM: Int = 768
} }
} }
@@ -0,0 +1,41 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.voice.embeddingtext.TextEmbedding
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/**
* Детерминированный [TextEmbeddingExtractor] для тестов: хеширует текст в
* псевдо-вектор фиксированной размерности. L2-normalize чтобы cosine
* работал осмысленно.
*
* НЕ suspend, как и положено extractor'у — suspend-обёртка живёт в
* [TextEmbeddingExecutor] (используется в VectorMemoryStore через
* [fakeExecutor]).
*/
class FakeTextEmbeddingExtractor(
val dimension: Int = 32,
) : TextEmbeddingExtractor {
override fun embed(text: String): TextEmbedding {
val v = FloatArray(dimension)
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
for (i in 0 until dimension) {
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
var norm = 0f
for (x in v) norm += x * x
norm = kotlin.math.sqrt(norm)
if (norm > 0f) for (i in v.indices) v[i] /= norm
return TextEmbedding(v)
}
override fun close() = Unit
}
/**
* Удобная обёртка для тестов: создаёт `FakeTextEmbeddingExtractor` и
* сразу заворачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью.
*/
fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor =
TextEmbeddingExecutor(FakeTextEmbeddingExtractor(dimension), knownDimension = dimension)
@@ -32,7 +32,7 @@ class VectorMemoryStoreTest {
// Загружаем начальные entries из metaStore (на случай если что-то там есть). // Загружаем начальные entries из metaStore (на случай если что-то там есть).
val seedEntries = metaStore.allEntries() val seedEntries = metaStore.allEntries()
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries) index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim)) store = VectorMemoryStore(index, metaStore, fakeEmbeddingExecutor(dimension = dim))
} }
@AfterTest @AfterTest
@@ -127,7 +127,7 @@ class VectorMemoryStoreTest {
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim) val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
val seedEntries = meta2.allEntries() val seedEntries = meta2.allEntries()
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries) val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim)) val store2 = VectorMemoryStore(idx2, meta2, fakeEmbeddingExecutor(dimension = dim))
try { try {
assertEquals(2L, idx2.size()) assertEquals(2L, idx2.size())
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5)) val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
@@ -141,11 +141,11 @@ class VectorMemoryStoreTest {
fun openSeedsIndexFromSqliteAfterRestart() = runTest { fun openSeedsIndexFromSqliteAfterRestart() = runTest {
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф // Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
// из SQLite — иначе после рестарта search возвращает [] до первого upsert. // из SQLite — иначе после рестарта search возвращает [] до первого upsert.
val first = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim)) val first = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle")) first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
first.close() first.close()
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim)) val second = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
try { try {
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5)) val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
assertTrue(results.any { it.note.id == "r" }) assertTrue(results.any { it.note.id == "r" })
@@ -17,17 +17,18 @@ import kotlin.test.assertTrue
class SiglipEmbeddingProviderTest { class SiglipEmbeddingProviderTest {
@Test @Test
fun `dimension is 768 when model loads successfully`() { fun `dimension is 768 when model loads successfully`() = runBlocking {
val modelDir = File("/tmp/text-emb-model") val modelDir = File("/tmp/text-emb-model")
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) { assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping" "SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
} }
SiglipEmbeddingProvider( val provider = SiglipEmbeddingProvider(
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx", modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model", tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
).use { provider -> ).asExecutor()
provider.use {
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings") assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") } val v = provider.embed("hello world")
assertEquals(768, v.size) assertEquals(768, v.size)
assertTrue(v.any { it != 0f }, "embedding should not be all zeros") assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
} }
@@ -41,7 +42,7 @@ class SiglipEmbeddingProviderTest {
SiglipEmbeddingProvider( SiglipEmbeddingProvider(
modelPath = nonExistent.absolutePath, modelPath = nonExistent.absolutePath,
tokenizerPath = nonExistent.absolutePath, tokenizerPath = nonExistent.absolutePath,
).use { it.dimension } )
} }
} }
@@ -49,3 +50,8 @@ class SiglipEmbeddingProviderTest {
org.junit.Assume.assumeTrue(message(), condition) org.junit.Assume.assumeTrue(message(), condition)
} }
} }
// runBlocking нужен потому что suspend-вызов provider.embed в suspend-тесте.
// Локальный импорт чтобы не тащить runBlocking в прод-код.
private fun <T> runBlocking(block: suspend () -> T): T =
kotlinx.coroutines.runBlocking { block() }
@@ -0,0 +1,57 @@
package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Live-события уровня агента: изменения в множестве диалогов
* (создание, удаление, переименование). События, происходящие **внутри**
* конкретного диалога, приходят через `Conversation.events` (live-stream
* per-turn Event'ов), а не сюда.
*
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
* идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла
* используются `Agent.getConversations` / `getConversation`.
*
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.AgentEvent`;
* typealias удалён 2026-09-21 (стирал nested-типы в `is`/`when`) — потребители
* импортируют напрямую из `pw.binom.agentik.outbox.AgentEvent`.
*/
@Serializable
sealed interface AgentEvent {
/** Момент эмиссии события в UTC. */
val date: Instant
/**
* Создан новый диалог. Передаётся его id — handle можно получить через
* `Agent.getConversation`. Подписчик после [Created] может сразу открыть
* live-подписку на этот диалог через `Conversation.events`.
*/
@Serializable
@SerialName("created")
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
/**
* Диалог удалён. Переданный `Conversation`-handle реализация обязана
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
* пользоваться handle нельзя.
*/
@Serializable
@SerialName("deleted")
data class Deleted(override val date: Instant, val id: String) : AgentEvent
/** У диалога сменился заголовок. */
@Serializable
@SerialName("renamed")
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
/**
* Обновлён `updatedAt` диалога (после `send()` или другого события,
* бампнувшего активность). Клиентский кэш [ConversationStore] может
* применить этот event для пересортировки списка.
*/
@Serializable
@SerialName("touched")
data class Touched(override val date: Instant, val id: String, val updatedAt: Instant) : AgentEvent
}
@@ -0,0 +1,42 @@
package pw.binom.agentik.outbox
import kotlin.time.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Unified wrapper for all agent events in a single stream.
*
* Useful for admin dashboards, debug tools, parent agents: one subscription
* instead of N+1. For regular UI use two separate SSE feeds
* ([AgentEvent] via `/events` и [Event] via `/conversations/{id}/events`);
* [CommonEvent] — for those who need everything in one place.
*
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
*
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
* но он стирал nested-типы (`CommonEvent.Agent`, `CommonEvent.Conversation`),
* что ломало `is CommonEvent.Agent` на стороне клиента. Typealias'ы
* `Event`/`AgentEvent`/`CommonEvent` из `:proto` удалены — потребители
* импортируют напрямую из `pw.binom.agentik.outbox.*`.
*/
@Serializable
sealed interface CommonEvent {
val date: Instant
@Serializable
@SerialName("agent")
data class Agent(
override val date: Instant,
val event: AgentEvent,
) : CommonEvent
@Serializable
@SerialName("conversation")
data class Conversation(
override val date: Instant,
val conversationId: String,
val event: Event,
) : CommonEvent
}
@@ -0,0 +1,165 @@
package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Элемент live-потока `Conversation.events(after)`.
*
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
* порядка при равных timestamps.
*
* Базовая структура хода:
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
*
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
* backward-compat, но он стирал nested-типы (`Event.End`, `Event.ToolCall`,
* `Event.ToolResult`), что ломало `is Event.End` на стороне клиента.
* Typealias удалён 2026-09-21 — потребители импортируют напрямую из
* `pw.binom.agentik.outbox.Event`.
*/
@Serializable
sealed interface Event {
/** Момент эмиссии события в UTC. */
val date: Instant
@Serializable
enum class ResponseType {
@SerialName("text") TEXT,
@SerialName("image") IMAGE
}
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
@Serializable
@SerialName("start_reasoning")
data class StartReasoning(override val date: Instant) : Event
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
@Serializable
@SerialName("start_response")
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
@Serializable
@SerialName("end")
data class End(override val date: Instant) : Event
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
@Serializable
@SerialName("append_text")
data class AppendText(override val date: Instant, val body: String) : Event
@Serializable
@SerialName("append_image")
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
/**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
* [id] совпадает с id соответствующего `Message.ToolCall` в истории
* после завершения хода.
*/
@Serializable
@SerialName("tool_call")
data class ToolCall(
override val date: Instant,
val id: String,
val title: String?,
val toolName: String,
val toolArgs: String,
) : Event
/**
* Результат вызова тула. Приходит целиком после завершения исполнения.
*
* [toolCallId] = id [ToolCall], к которому относится результат, и
* `MessageRecord.ToolResult.toolCallId` в истории. Один Call → один Result,
* пара `(date, toolCallId)` уникальна — отдельный `id` в live-событии
* не нужен (PK живёт в персистентном журнале).
*
* [toolName] денормализован из соответствующего [ToolCall.toolName] —
* UI рендерит имя тула без локальной `Map<toolCallId, name>` и без риска
* «Result пришёл до Call». `null` допустим для backfill'а старых
* записей, у которых поле отсутствует, или теоретического случая
* Result без предшествующего Call (orphan).
*/
@Serializable
@SerialName("tool_result")
data class ToolResult(
override val date: Instant,
val toolCallId: String,
val toolName: String? = null,
val result: String?,
) : Event
/**
* Ошибка хода. После неё поток завершается; дальнейшие события могут
* прийти, но ход считается проваленным.
*/
@Serializable
@SerialName("error")
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
/**
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
* kernel прибил таск). Отличается от [ToolResult]: там мы сообщаем LLM
* результат (даже если LLM его не понравился), тут — сигнал о
* внутренней ошибке **самого исполнения тула**. Используется
* background-подписчиками (например [ReflectionScheduler]-like
* компонентами) для накопления паттернов отказов. Клиенту
* показывается для transparency, но в UI особо не нужен.
*/
@Serializable
@SerialName("tool_failed")
data class ToolFailed(
override val date: Instant,
val toolCallId: String,
val toolName: String?,
val message: String,
val durationMs: Long,
) : Event
/**
* Диалог переходит в закрытое состояние ([Conversation.close] /
* [ConversationLoop.close] / `agent.deleteConversation`). Эмитится
* **до** освобождения ресурсов, чтобы background-подписчики
* (skill mining, reflection) успели сделать final pass. После
* `Closed` диалог уже удалён из `agent.getConversations()` и
* `getMessages()` отдаст только то, что осталось в журнале.
*
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
* так как открытие тривиально (id уже известен с момента
* `Agent.createConversation` → [Event.ConversationCreated]
* / [AgentEvent.Created] в outbox'е).
*/
@Serializable
@SerialName("conversation_closing")
data class ConversationClosing(
override val date: Instant,
val conversationId: String,
) : Event
/**
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
* Эмитится **до** deletion для background-подписчиков (skill mining),
* чтобы они успели сделать pass на исчезающем контенте.
*
* В отличие от [ToolFailed]/[ConversationClosing], это событие
* семантически "много контента ушло" — subscribers могут решать,
* стоит ли тратить tokens на mining ([turnsCompacted] > N).
*/
@Serializable
@SerialName("compaction_triggered")
data class CompactionTriggered(
override val date: Instant,
val conversationId: String,
val turnsCompacted: Int,
) : Event
}
+29
View File
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [MutableOutboxStore] на `ArrayDeque` + `Mutex` — для тестов,
// dev-режима и embedded-сценариев (Android core, CLI). TTL и size-cap eviction
// вызываются на каждом `append`, в одном проходе с amortized O(1) для стабильного
// размера буфера.
//
// Зависимости: только `:outbox-api` (api → `:proto` транзитивно).
// Никакого I/O — pure in-memory.
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":outbox-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,160 @@
package pw.binom.agentik.outbox.inmemory
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.outbox.MutableOutboxStore
import pw.binom.agentik.outbox.CommonEvent
/**
* In-memory реализация [MutableOutboxStore] на `ArrayDeque` + [Mutex].
*
* **Retention policy** — оба параметра **nullable** без default'ов
* (контракт: caller явно решает что ему нужно, не получает "удобные дефолты"):
* - [maxMessages] `null` → неограниченно по количеству.
* - [ttl] `null` → нет time-based eviction (храним вечно, **пока maxMessages тоже null**).
* - **Оба `null` → вечное хранилище.**
* - Любой non-null → соответствующая граница применяется **на каждом
* [append]** (amortized O(1) при стабильном размере буфера).
*
* **Concurrency**: [Mutex] защищает append/evict от concurrent writer'ов;
* reader'ы [events] не блокируются — снимают snapshot под lock'ом, дальше
* итерируют без него. Snapshot под `mutex.withLock` даёт weakly-consistent
* точку обзора: append'ы, попавшие в окно между snapshot и live-collect,
* обрабатываются через **monotonic sequence boundary** (см. [events] KDoc).
*
* **Live tail**: [MutableSharedFlow] с DROP_OLDEST policy. Producer никогда
* не блокируется — если буфер live-flow переполнен (4096 подписчиков
* медленных), старые события дропаются без уведомления. Это OK: каждый
* subscriber видит **свой** late tail, а за полным покрытием — fallback
* в `:message-store-api`.
*
* **Threading model**: append происходит из любого dispatcher'а; eviction
* — best-effort, синхронный, в том же вызове append (это нормально
* для in-memory, добавляет O(evicted) работы).
*/
class InMemoryOutboxStore(
private val maxMessages: Int?,
private val ttl: Duration?,
private val clock: Clock = Clock.System,
) : MutableOutboxStore {
private val mutex = Mutex()
private val buffer = ArrayDeque<CommonEvent>()
private val liveFlow = MutableSharedFlow<CommonEvent>(
replay = 0,
extraBufferCapacity = LIVE_BUFFER_CAPACITY,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
// Аргументы — НЕ optional default'ы; explicit null = "не применяется".
// Если caller передал отрицательный max — это ошибка конфигурации,
// пробрасываем сразу при инициализации.
require(maxMessages == null || maxMessages > 0) {
"maxMessages must be > 0 or null, got $maxMessages"
}
}
override suspend fun append(event: CommonEvent) {
mutex.withLock {
buffer.addLast(event)
}
liveFlow.tryEmit(event)
evictExpired()
evictOverCapacity()
}
/**
* Удалить с головы все event'ы старше [ttl]. Amortized O(evicted).
* Если [ttl] null — no-op.
*/
private suspend fun evictExpired() {
val ttlValue = ttl ?: return
val cutoff = clock.now() - ttlValue
mutex.withLock {
while (true) {
val head = buffer.firstOrNull() ?: return@withLock
if (head.date >= cutoff) return@withLock
buffer.removeFirst()
}
}
}
/**
* Удалить с головы пока размер > [maxMessages]. Amortized O(evicted).
* Если [maxMessages] null — no-op.
*/
private suspend fun evictOverCapacity() {
val cap = maxMessages ?: return
mutex.withLock {
while (buffer.size > cap) {
if (buffer.isEmpty()) return@withLock
buffer.removeFirst()
}
}
}
override fun events(after: Instant?): Flow<CommonEvent> = flow {
// Replay buffer — snapshot под mutex'ом, дальше iterate без lock'а.
// Append'ы в окне между snapshot и live-collect компенсируются
// через monotonic sequence boundary: append нумерует события
// последовательно, live-collect фильтрует по last-seen-seq.
val snapshot: List<CommonEvent> = mutex.withLock {
if (after == null) {
buffer.toList()
} else {
buffer.filter { it.date > after }
}
}
snapshot.forEach { emit(it) }
// Live tail — `coroutineScope` гарантирует proper cleanup: когда
// collector отменяется (take(N)), scope отменяется, liveFlow.collect
// выходит чисто. Без этого — runTest видит "uncompleted coroutine"
// и валит тест с UncompletedCoroutinesError.
coroutineScope {
liveFlow.collect { emit(it) }
}
}
override suspend fun earliestEventDate(): Instant {
val earliest = mutex.withLock { buffer.firstOrNull()?.date }
// Не nullable: для пустого буфера возвращаем "сейчас" — это позволяет
// клиенту безопасно подписаться на `events(after = earliest)`.
return earliest ?: clock.now()
}
/**
* **Test-only helper** — снимок буфера в текущий момент.
*
* `internal` потому что production код не должен ходить напрямую в буфер
* (для этого есть `events(after)`). Доступно только из `commonTest`.
*
* Returns: иммутабельный snapshot (копия). Под `mutex.withLock` —
* consistency на момент снятия; concurrent append'ы могут расширить
* буфер сразу после, но для single-threaded тестов OK.
*/
internal suspend fun snapshot(): List<CommonEvent> = mutex.withLock { buffer.toList() }
override fun close() {
// mutex не закрываем (kotlinx Mutex не AutoCloseable; для in-memory
// store GC соберёт всё при выходе ссылки). buffer чистим.
buffer.clear()
}
private companion object {
// Live-flow capacity — generous default. Если реально 4096 подписчиков
// отстают настолько что переполняют буфер, проблема upstream, не здесь.
private const val LIVE_BUFFER_CAPACITY = 4096
}
}
@@ -0,0 +1,228 @@
package pw.binom.agentik.outbox.inmemory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
// Импортируем напрямую из :outbox-api — typealias'ы в :proto для
// CommonEvent/AgentEvent/Event НЕ поддерживают nested-class access
// (`CommonEvent.Agent` через alias даёт "Unresolved qualified name").
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
class InMemoryOutboxStoreTest {
private class FixedClock(private var nowMs: Long = 1_000_000_000L) : Clock {
fun advance(delta: Duration) { nowMs += delta.inWholeMilliseconds }
override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs)
}
private fun evtAt(clock: Clock, body: String): CommonEvent =
CommonEvent.Agent(date = clock.now(), event = AgentEvent.Created(date = clock.now(), conversationId = body))
@Test
fun `append stores all events when both limits are null store-forever`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
repeat(100) { i ->
store.append(CommonEvent.Agent(
date = Instant.fromEpochSeconds(i.toLong()),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
))
}
assertEquals(100, store.snapshot().size)
}
@Test
fun `maxMessages cap evicts oldest when exceeded`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = 3, ttl = null)
for (i in 1..5) {
store.append(CommonEvent.Agent(
date = Instant.fromEpochSeconds(i.toLong()),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
))
}
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("c-3", "c-4", "c-5"), ids)
}
@Test
fun `ttl evicts events older than threshold`() = runBlocking {
val clock = FixedClock()
val store = InMemoryOutboxStore(maxMessages = null, ttl = 100.milliseconds, clock = clock)
store.append(evtAt(clock, "old"))
clock.advance(50.milliseconds)
store.append(evtAt(clock, "middle"))
clock.advance(70.milliseconds)
store.append(evtAt(clock, "fresh"))
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("middle", "fresh"), ids)
}
@Test
fun `both maxMessages and ttl apply together`() = runBlocking {
val clock = FixedClock()
// ttl=100ms so b at t=20 (deadline=120) survives when c is appended at t=80.
// Cap=2 evicts oldest. Result: [b, c].
val store = InMemoryOutboxStore(maxMessages = 2, ttl = 100.milliseconds, clock = clock)
store.append(evtAt(clock, "a"))
clock.advance(20.milliseconds)
store.append(evtAt(clock, "b"))
clock.advance(60.milliseconds)
store.append(evtAt(clock, "c"))
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("b", "c"), ids)
}
@Test
fun `events with null after replays buffer then collects live`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
store.append(evtAt(Clock.System, "e1"))
store.append(evtAt(Clock.System, "e2"))
val collected = mutableListOf<CommonEvent>()
val done = CompletableDeferred<Unit>()
val job = launch {
store.events(after = null).collect { e ->
collected.add(e)
if (collected.size >= 3) done.complete(Unit)
}
}
delay(20)
store.append(evtAt(Clock.System, "e3"))
done.await()
job.cancel()
assertEquals(3, collected.size)
}
@Test
fun `events with after catches up then continues with live`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val t0 = Instant.fromEpochSeconds(0)
val t1 = Instant.fromEpochSeconds(10)
val t2 = Instant.fromEpochSeconds(20)
store.append(CommonEvent.Agent(date = t0, event = AgentEvent.Created(date = t0, conversationId = "e1")))
store.append(CommonEvent.Agent(date = t1, event = AgentEvent.Created(date = t1, conversationId = "e2")))
store.append(CommonEvent.Agent(date = t2, event = AgentEvent.Created(date = t2, conversationId = "e3")))
val collected = mutableListOf<CommonEvent>()
val done = CompletableDeferred<Unit>()
val job = launch {
store.events(after = t0).collect { e ->
collected.add(e)
if (collected.size >= 3) done.complete(Unit)
}
}
delay(20)
store.append(CommonEvent.Agent(
date = Instant.fromEpochSeconds(30),
event = AgentEvent.Created(date = Instant.fromEpochSeconds(30), conversationId = "e4"),
))
done.await()
job.cancel()
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
assertEquals(listOf("e2", "e3", "e4"), ids)
}
@Test
fun `earliestEventDate returns oldest buffered date`() = runBlocking {
val clock = FixedClock()
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
store.append(evtAt(clock, "e1"))
clock.advance(100.milliseconds)
store.append(evtAt(clock, "e2"))
assertEquals(Instant.fromEpochMilliseconds(1_000_000_000L), store.earliestEventDate())
}
@Test
fun `earliestEventDate returns current time when buffer is empty`() = runBlocking {
val clock = FixedClock(nowMs = 5_000_000_000L)
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
assertEquals(Instant.fromEpochMilliseconds(5_000_000_000L), store.earliestEventDate())
}
@Test
fun `conversationEvents default impl filters to conversation variant`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Agent(
date = now,
event = AgentEvent.Created(date = now, conversationId = "agent-event"),
))
store.append(CommonEvent.Conversation(
date = now,
conversationId = "c-1",
event = Event.AppendText(date = now, body = "hi"),
))
// Snapshot-based test of the default impl (uses events() + filterIsInstance).
// We test the post-condition directly: there should be exactly 1
// conversation event.
val all = store.snapshot()
assertEquals(2, all.size)
assertEquals(1, all.count { it is CommonEvent.Conversation })
assertEquals(1, all.count { it is CommonEvent.Agent })
}
@Test
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "a")))
store.append(CommonEvent.Conversation(now, "c-2", Event.AppendText(now, "b")))
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "c")))
// Test the filter logic by manually filtering snapshot.
val c1 = store.snapshot()
.filterIsInstance<CommonEvent.Conversation>()
.filter { it.conversationId == "c-1" }
assertEquals(2, c1.size)
assertTrue(c1.all { it.conversationId == "c-1" })
}
@Test
fun `agentEvents default impl filters to agent variant`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
val now = Instant.fromEpochSeconds(0)
store.append(CommonEvent.Agent(
date = now,
event = AgentEvent.Created(date = now, conversationId = "created"),
))
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "hi")))
val all = store.snapshot()
val agents = all.filterIsInstance<CommonEvent.Agent>()
assertEquals(1, agents.size)
val created = agents[0].event as AgentEvent.Created
assertEquals("created", created.conversationId)
}
@Test
fun `close clears buffer`() = runBlocking {
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
store.append(evtAt(Clock.System, "e1"))
store.close()
assertEquals(emptyList(), store.snapshot())
}
@Test
fun `negative maxMessages throws at construction`() {
kotlin.runCatching { InMemoryOutboxStore(maxMessages = -1, ttl = null) }
.onFailure { /* expected */ }
.onSuccess { kotlin.test.fail("should have thrown") }
}
}
private val Int.milliseconds: Duration get() = Duration.parse("${this}ms")
@@ -1,7 +1,6 @@
package pw.binom.agentik.proto package pw.binom.agentik.proto
import kotlinx.coroutines.flow.Flow import pw.binom.agentik.journal.ConversationStore
import kotlinx.coroutines.flow.flow
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import kotlin.time.Instant import kotlin.time.Instant
@@ -13,11 +12,16 @@ import kotlin.time.Instant
* [createConversation] возвращает [Conversation], который сам хранит историю * [createConversation] возвращает [Conversation], который сам хранит историю
* и которому отправляют ходы через [Conversation.send]. * и которому отправляют ходы через [Conversation.send].
* *
* **Хранилища вынесены в [Agent.journal] и [Agent.outbox]**: оба read-only. * **Хранилища вынесены в [Agent.journal], [Agent.outbox] и
* События больше НЕ часть [Agent] (раньше были `events()`/`allEvents()`) — * [Agent.conversationStore]**: все три read-only views. События живут
* они теперь живут в [outbox] как `OutboxStore.events(after)` / * в [outbox] как `OutboxStore.events(after)` / `outbox.agentEvents(after)`.
* `outbox.agentEvents(after)`. Это даёт единый путь для всех read-операций * Это даёт единый путь для всех read-операций по хранилищу и убирает
* по хранилищу и убирает дублирование между протоколом и хранилищем. * дублирование между протоколом и хранилищем.
*
* **Команды** (create / delete / rename) живут прямо на [Agent]. Они
* шлются клиентом и выполняются сервером — клиент **не** пишет в стор
* напрямую. Клиентский кэш [conversationStore] обновляется через
* `outbox.agentEvents()` (Created / Deleted / Renamed / Touched).
*/ */
interface Agent : AutoCloseable { interface Agent : AutoCloseable {
@@ -59,6 +63,22 @@ interface Agent : AutoCloseable {
*/ */
val outbox: OutboxStore val outbox: OutboxStore
/**
* Read-only view на `conversation` table (id + title + timestamps).
*
* Используется HTTP-фасадом `:server` для endpoint'а
* `GET /{path}/conversations?offset=&limit=` — внешние клиенты
* получают лёгкую метадату (без handle'ов и image-support флагов)
* для рендера списка диалогов. Для активной работы (send / interrupt)
* клиент отдельно получает handle через [getConversation].
*
* **Read-only**: write-доступ только через `MutableConversationStore`
* внутри ChatAgent, не через [Agent] interface. Клиент модифицирует
* диалоги командами: [createConversation] / [deleteConversation] /
* [renameConversation].
*/
val conversationStore: ConversationStore
/** Создаёт новый stateful-диалог с агентом. */ /** Создаёт новый stateful-диалог с агентом. */
fun createConversation(temp: Boolean): Conversation fun createConversation(temp: Boolean): Conversation
@@ -68,19 +88,11 @@ interface Agent : AutoCloseable {
/** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */ /** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */
suspend fun deleteConversation(id: String): Boolean suspend fun deleteConversation(id: String): Boolean
/** Страница диалогов: не более [limit] штук, начиная с [offset]-го. */ /**
suspend fun getConversations(offset: Int, limit: Int): List<Conversation> * Переименовывает диалог; `null` для сброса заголовка. Возвращает новый
* `updatedAt` или `null`, если диалог не найден.
/** Все диалоги, начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */ */
fun getConversations(offset: Int = 0): Flow<Conversation> = flow { suspend fun renameConversation(id: String, title: String?): Instant?
var skip = offset
while (true) {
val page = getConversations(skip, PAGE_SIZE)
if (page.isEmpty()) break
page.forEach { emit(it) }
skip += page.size
}
}
companion object { companion object {
@@ -1,10 +0,0 @@
package pw.binom.agentik.proto
/**
* Backward-compat typealias: `AgentEvent` теперь живёт в `:outbox-api`
* (логически принадлежит сущности outbox, не wire-протоколу `:proto`).
*
* Существующие импорты `pw.binom.agentik.proto.AgentEvent` продолжают
* работать транспарентно. Использовать typealias в новом коде.
*/
typealias AgentEvent = pw.binom.agentik.outbox.AgentEvent

Some files were not shown because too many files have changed in this diff Show More