39 Commits

Author SHA1 Message Date
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
subochev f946186ef5 feat(client): refactor AgentikAgent to manage its own HttpClient
ci / JVM build + tests (push) Failing after 54s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 5s
- `AgentikAgent` now accepts `engineFactory` and an optional `token` to create an internal `HttpClient`, handling all configuration (JSON, Bearer).
- Removed `applyAgentikDefaults` and replaced it with `agentikHttpClient` for `HttpClient` creation with consistent settings.
- Updated `Agent` to implement `AutoCloseable`, ensuring proper resource closure with `agent.close()`.
- Adjusted tests, docs, and examples to align with the new `AgentikAgent` API.
2026-09-21 03:53:35 +03:00
subochev fb963bfb6b feat(journal-inmemory): add :journal-inmemory module with InMemoryJournalStore
ci / JVM build + tests (push) Failing after 1m2s
- Introduced a new `:journal-inmemory` module implementing `:journal-api` with an in-memory backend.
- Added `InMemoryJournalStore` for concurrent append, list, and clear operations using `Mutex` and `MutableList`.
- Use cases include tests, dev mode, embedded scenarios, and client-side in-process caching.
- Integrated the module into the project setup and documented usage in `:client/README.md`.
- Added comprehensive
2026-09-21 03:45:40 +03:00
subochev 818f022ba3 remove pw.binom.agentik.standalone.agent and memory modules along with related utilities, tools, and background processing
ci / JVM build + tests (push) Failing after 59s
2026-09-21 03:32:22 +03:00
subochev 0d1be42919 remove :message-store-api and migrate functionality to :journal-api and :reflection-api
ci / JVM build + tests (push) Failing after 59s
- Removed `:message-store-api` module and associated classes (ConversationStore, ReflectionStore, Ids, etc.).
- Migrated reusable components to `:journal-api` (conversation-related) and `:reflection-api` (reflection-related).
- Updated imports and module dependencies across all projects to reflect new structure.
- Adjusted build scripts and tests for compatibility with the new APIs.
2026-09-21 03:24:20 +03:00
subochev 2d6cf89c52 remove deprecated EventStore and MessageStore implementations, along with related in-memory and SQLite code
ci / JVM build + tests (push) Failing after 59s
2026-09-21 02:57:45 +03:00
subochev 8f85612665 feat(client): implement HttpJournalStore and integrate journal endpoints
ci / JVM build + tests (push) Failing after 1m8s
- Added `HttpJournalStore` as an HTTP-backed implementation of `JournalStore` for read-only access to the audit log.
- Integrated `GET /journal/conversations/{id}/messages` endpoint to fetch conversation transcripts with full payloads.
- Updated `AgentClient` to expose `HttpJournalStore` as the `journal` property.
- Adjusted `HttpEventStore` to align with updated endpoint structure (`/outbox/events`).
2026-09-21 02:43:29 +03:00
subochev aef5083801 refactor: migrate EventStore and MessageStore to :outbox-api and :journal-api
ci / JVM build + tests (push) Failing after 1m9s
- Replaced usages of `:message-store-api` and `:working-memory-api` with `:journal-api`, `:outbox-api`, and `:context-api`.
- Deprecated legacy `EventStore` and `MessageStore` interfaces, added `typealias` for backward compatibility.
- Updated imports across all modules with references to `:journal-api` and `:outbox-api`.
- Introduced `journalRoutes` and `outboxRoutes` in `:server` for audit log and live event stream endpoints.
- Adjusted `Agent` to expose read-only `journal` and `outbox` stores for improved modularity and clarity.
- Removed legacy Event and AgentEvent definitions from `:proto`, migrated to `:outbox-api`.
- Storage-related modules have been updated to support the new APIs consistently.
2026-09-21 02:38:40 +03:00
subochev 499db812ef feat(journal-ksqlite): add :journal-ksqlite module with KsqliteJournalStore implementation
ci / JVM build + tests (push) Failing after 1m10s
- Introduced a new `:journal-ksqlite` module implementing `:journal-api` with Ksqlite backend.
- Added a minimal schema (message table + 1 index) for append-only audit logs, eliminating modifications and ensuring immutable storage.
- Provides `KsqliteJournalStore` for appending, listing, and clearing messages with JSON-encoded payloads.
- Autonomous schema migration (`Schema.migrate`) and in-memory tests validate functionality.
- Partial duplication of `:storage-ksqlite/KsqliteMessageStore`; consumers will transition gradually.
2026-09-21 01:23:18 +03:00
subochev d6afc05c20 feat(context-ksqlite): add :context-ksqlite module with KsqliteContextStore implementation
ci / JVM build + tests (push) Failing after 1m6s
- Introduced a new `:context-ksqlite` module implementing `:context-api` with Ksqlite backend.
- Added a minimal schema (working_memory table + 2 indexes) for runtime context store: lightweight, excludes conversation/message/reflection tables, which are handled in separate modules.
- Initial version supports JVM, Linux, and Windows builds; migrates schema using SQLite PRAGMA.
- Includes fully autonomous in-memory schema migration and tests to validate operations like append, list, clear, and compact.
- Partial duplication of `:storage-ksqlite/KsqliteWorkingMemoryStore`; consumers will migrate incrementally.
2026-09-21 01:18:32 +03:00
subochev acc7237e51 Коммит заменяет SQLDelight на ksqlite и добавляет journal/context/outbox
ci / JVM build + tests (push) Failing after 1m24s
2026-09-21 01:12:31 +03:00
subochev bd65c29b48 refactor(message-log-api): split MessageStore into read-only and mutable interfaces
ci / JVM build + tests (push) Successful in 6m14s
- Introduced `MutableMessageStore` for producers with an `append` operation, separate from read-only `MessageStore`.
- Updated all consumers and implementations to use the appropriate interface (`read-only` for observers, `mutable` for producers).
- Improves modularity and ensures compile-time guarantees against unintended write operations in the audit log.
2026-09-20 21:08:55 +03:00
subochev 2cc1d923b3 refactor(message-log-api): move TokenStats to a separate file for improved modularity
ci / JVM build + tests (push) Has been cancelled
2026-09-20 21:03:31 +03:00
subochev 8f8f7f1020 refactor(message-log-api): reorganize classes into separate files for clarity
ci / JVM build + tests (push) Has been cancelled
- Extracted `TurnTokens`, `MessageOrigin`, and `MessageEvent` into standalone files.
- Simplified `MessageContext` by externalizing `MessageOrigin`.
- Improves modularity and code maintainability for append-only audit logs.
2026-09-20 21:02:50 +03:00
subochev 3f260f3ac8 refactor(client): drop agentikHttpClient factory, accept HttpClient directly
ci / JVM build + tests (push) Successful in 6m3s
- Remove agentikHttpClient(engineFactory: HttpClientEngineFactory<T>, ...)
  factory from :client; the typed factory parameter pulled engine-specific
  config (HttpClientConfig<T>.engine { ... }) and made the library's
  factory API look engine-coupled even though the factory itself was
  engine-agnostic.
- KDoc and SseTimeout no longer reference CIO as the canonical example.
- :client still exports applyAgentikDefaults(HttpClientConfig<*>) extension;
  consumers build HttpClient themselves with their chosen engine.
- :agentik-cli/defaultCliHttpClient updated to construct HttpClient(CIO)
  itself and apply agentik defaults inline; CIO-specific config
  (requestTimeout = 0) stays in the same block.
2026-09-20 20:47:13 +03:00
subochev d74af621d8 refactor(storage): drop StorageBundle + legacy EventStore, add HttpEventStore
ci / JVM build + tests (push) Successful in 6m13s
- Remove legacy pw.binom.agentik.messageStore.events.EventStore (EventRecord,
  EventType) and all three impls (in-memory, sqlite, ksqlite) + tests + .sq
- Drop :storage-bundle module entirely; ChatAgent / ChatConversation /
  ConversationLoop / DebugRoutes now take stores individually
  (conversationStore, messageStore, workingMemoryStore, reflectionStore,
  eventStore) instead of StorageBundle
- Delete server endpoints /events/replay and /conversations/{id}/events/replay;
  Route.agentikAgent no longer takes eventStore param
- Add :client/HttpEventStore implementing :event-store/EventStore over HTTP:
  events() -> GET /events/all, agentEvents() -> GET /events,
  conversationEvents(convId) -> GET /conversations/{id}/events;
  exposed via AgentClient.eventStore
- :event-store: add macosX64/macosArm64/linuxArm64 targets to match :client KMP
- :working-memory-api: drop api dep on :message-store-api (no longer needed)
- :storage-{inmemory,sqlite,ksqlite}: drop deps on :storage-bundle
2026-09-20 18:06:46 +03:00
subochev 15f3952eba refactor(storage): split MessageStore into :message-log-api
ci / JVM build + tests (push) Successful in 6m15s
Выделяет append-only message log в отдельный KMP-модуль.
Цель — разделить ДВЕ сущности по своей природе:

  :message-log-api  — append-only audit log (User/Assistant/ToolCall/
                       ToolResult/Error). Никаких update, только insert + read.
                       Это иммутабельная история диалога.

  :working-memory-api — mutable runtime context (compact, summary, WM order).
                          Live state. Compaction-логика.

Раньше оба жили в :message-store-api, что:
  - смешивало контракты: append-only audit vs mutable runtime;
  - делало невозможным лёгкого клиента который читает только audit log
    без WM-runtime зависимости;
  - затрудняло compaction-логике жить в одном модуле с audit-записью.

Миграция:
  - В :message-log-api переехали: Content, MessageRecord, MessageStore,
    MessageContext (с MessageOrigin), MessageEvent, TokenStats, TurnTokens,
    helpers (encode/decodeBodyPayload, MessageBodyPayload, BodyDecoded).
    Пакет pw.binom.agentik.messageLog.
  - В :message-store-api остались: ConversationStore, ConversationRecord,
    ReflectionStore, Ids, legacy events.EventStore (paginated replay).
    Пакет pw.binom.agentik.messageStore.
  - :working-memory-api: обновил deps (api → :message-log-api для Content/MessageContext).
  - 23 consumer-файла обновлены (FQN renames).
  - storage-sqlite/ksqlite: убраны недостижимые ветки Summary/System
    (эти synthetic records живут ТОЛЬКО в :working-memory-api, не попадают
    в audit log :message-log-api).

Файлы:
  + :message-log-api (5 файлов, ~280 строк)
  - :message-store-api (5 файлов, ~430 строк)
  ~ 23 файла обновлены

Совместимость схем не меняется. Все 5 storage impl'ов (3 backend × 5 store)
работают на тех же таблицах.
2026-09-20 17:21:46 +03:00
subochev a0b1209457 feat(event-store-in-memory): InMemoryEventStore implementation of MutableEventStore
ci / JVM build + tests (push) Failing after 1m58s
Первый concrete impl :event-store. ConcurrentLinkedDeque + eviction на
каждом append (amortized O(1) при стабильном размере буфера).

Контракт (по требованию пользователя):
  - maxMessages: Int?  — nullable, NO default (явный null = unlimited)
  - ttl: Duration?      — nullable, NO default (явный null = forever)
  - оба null → store forever
  - любой non-null → соответствующий eviction policy

Eviction:
  - evictExpired(): pollFirst пока head.date < (now - ttl)
  - evictOverCapacity(): pollFirst пока size > maxMessages
  - обе вызываются синхронно на каждом append

Live tail: MutableSharedFlow(capacity=4096, DROP_OLDEST).
Producer никогда не блокируется — slow subscriber получает свежие события,
за полным покрытием — fallback в :message-store-api.

Tests (13):
  - append stores all (both null)
  - maxMessages cap evicts
  - ttl evicts older than threshold
  - both policies apply together (cap=2 + ttl=100ms, a/b/c appends)
  - events(null) replays buffer then collects live
  - events(after) catches up + live
  - earliestEventDate (oldest + empty buffer = now)
  - conversationEvents/agentEvents фильтры (default impl в :event-store)
  - close clears buffer
  - negative maxMessages throws at construction

internal helper snapshot() для тестов — production code использует
events()/events(after) для доступа к буферу.

KMP: jvm + linuxX64 + mingwX64.
2026-09-20 16:30:09 +03:00
subochev a05e260457 refactor(event-store): default impls for conversationEvents/agentEvents
ci / JVM build + tests (push) Failing after 1m24s
Методы conversationEvents() и agentEvents() теперь default в интерфейсе:
реализуют фильтрацию через [events] + filterIsInstance.

Зачем:
  - Минимальный контракт для impl — достаточно реализовать только events().
  - InMemoryEventStore и любой новый backend получают работающие
    specialized views автоматически, без копипасты filterIsInstance.
  - Persistent impl'ы (SQL/ksqlite) могут override'нуть для эффективности
    (WHERE conversation_id = ? — не тянуть все events в память), но
    контракт корректен и без override.

Семантика идентична:
  - conversationEvents(after, null)   = events().filterIsInstance<Conversation>()
  - conversationEvents(after, "c-1") = events()...filter { it.conversationId == "c-1" }
  - agentEvents(after)               = events().filterIsInstance<Agent>()

Imports добавлены: kotlinx.coroutines.flow.filter, filterIsInstance.
2026-09-20 15:56:59 +03:00
subochev 7e66baf9e3 feat(event-store): add conversationEvents and agentEvents filters
ci / JVM build + tests (push) Has been cancelled
Расширяет EventStore тремя вариантами подписки (вместо одного events()):

  - events(after)               → Flow<CommonEvent>
      весь поток (микс Agent + Conversation)

  - conversationEvents(after, conversationId?)
      → Flow<CommonEvent.Conversation>
      опциональный фильтр по conversationId (null = все диалоги)

  - agentEvents(after)
      → Flow<CommonEvent.Agent>
      только lifecycle (Created/Deleted/Renamed)

Типизированные subtype'ы вместо Flow<CommonEvent> + .filterIsInstance:
  - compile-time safety на клиенте (нет cast'ов в CommonEvent.Conversation)
  - persistent impl'ы могут делать WHERE conversation_id = ? на уровне БД

Соответствует HTTP-маршрутам в :server:
  - GET /events/all                       ↔ events(after)
  - GET /conversations/{id}/events         ↔ conversationEvents(after, id)
  - GET /events (только agent lifecycle)   ↔ agentEvents(after)

README обновлён — таблица caller→method показывает маппинг.

Совместимость: signals не сломаны (добавление, не изменение).
2026-09-20 15:55:14 +03:00
subochev 1134e32ea2 docs(event-store): README explaining module purpose and contract
ci / JVM build + tests (push) Failing after 1m16s
Документирует:
  - Три принципа дизайна (TTL внутри, catchup+live в одном Flow,
    read-only контракт для observer'ов)
  - Архитектуру двухуровневого хранилища событий со схемой
  - Reconnect pattern с gap detection
  - API EventStore + MutableEventStore (когда какой использовать)
  - Таблица: какой caller принимает какой интерфейс
  - Текущее состояние: interfaces готовы, implementations в работе
  - Зависимости (минимальные: :proto + kotlinx-coroutines)

В том же стиле что и :agent-toolsets/README.md.
2026-09-20 15:51:21 +03:00
subochev e2f0e434d1 refactor(event-store): split into EventStore (read-only) + MutableEventStore
ci / JVM build + tests (push) Failing after 1m49s
Разделяет интерфейс на read-only (EventStore) и write (MutableEventStore).

EventStore (read-only, для consumer'ов):
  - events(after: Instant?): Flow<CommonEvent>
  - earliestEventDate(): Instant
  - close()

MutableEventStore : EventStore (для producer'ов):
  - + append(event: CommonEvent)
  - suspend, не идемпотентный, может быть silently evicted

Зачем:
  - Consumer'ы (server SSE, admin dashboard, parent agents) принимают
    EventStore — compile-time гарантия что они не могут писать в store.
  - Producer'ы (ChatAgent, sub-agents, A2A-bridge) принимают MutableEventStore.
  - Тесты могут использовать EventStore без опасности случайной модификации.

Миграция:
  - :event-store пока без implementations, поэтому ничего не сломалось.
  - Когда добавим InMemoryEventStore — он будет реализовывать оба
    (MutableEventStore = EventStore + append). Подписки получают только
    read-only projection через приведение типа.

Также: импорт обновлён AllEvent → CommonEvent (по rename в :proto).
2026-09-20 15:46:59 +03:00
subochev 1f85cde1b8 feat(event-store): store and emit AllEvent directly
ci / JVM build + tests (push) Failing after 1m17s
Заменяет generic Event envelope (id, date, payload) на типизированный AllEvent
из :proto. EventStore теперь:

  - append(event: AllEvent)
  - events(after: Instant?): Flow<AllEvent>
  - earliestEventDate(): Instant

Изменения:
  - :event-store теперь зависит от :proto (api dependency).
  - Event.kt удалён — AllEvent уже живёт в :proto и несёт date, conversationId,
    typed envelope (Agent или Conversation variant).
  - Generic opaque payload выкинут — typesafety до самого storage.
  - append НЕ идемпотентен (AllEvent не имеет уникального id, два retry
    дадут дубликат). Документировано: для exactly-once использовать catchup
    через :message-store-api (там есть монотонный id).

В KDoc примере reconnect убран лишний null-check у earliestEventDate
(теперь всегда non-null).

Миграция (когда будем интегрировать):
  Producer в :standalone перестаёт делать двойную работу
  (MutableSharedFlow + persistAgentEvent). Вместо этого — один
  eventStore.append(allEvent). Caller'ы SSE будут делать
  store.events(after) → Flow<AllEvent>, без ручной конвертации.

Build green на JVM.
2026-09-20 15:44:17 +03:00
subochev 8bb24dab3c refactor(event-store): earliestEventDate returns non-nullable Instant
ci / JVM build + tests (push) Failing after 1m50s
Раньше возвращал Instant? — null для пустого буфера. Клиенту приходилось
проверять if (earliest != null && ...) — легко ошибиться.

Теперь всегда Instant. Если буфер пуст, возвращает Clock.System.now()
на момент вызова. Это убирает nullable + сохраняет семантически корректное
поведение:

  - Клиент может безопасно сделать store.events(earliest) → получит только
    live event'ы, без ложного catchup.
  - Если бы возвращали null/DISTANT_PAST/null-check, клиент мог бы ошибочно
    подписаться на несуществующий catchup-диапазон.

Edge case (в KDoc): клиент, подключившийся ДО первого event'а, получает
earliest ≈ now. Его lastSeen < earliest → адаптируется в первом poll'е.

Изменение breaking — но :event-store ещё не имеет implementations и
никакие consumer'ы не используют этот метод.
2026-09-20 15:41:30 +03:00
subochev 7358175499 feat(event-store): new :event-store module with interfaces only
ci / JVM build + tests (push) Failing after 1m54s
Выделяет bounded-tail event log в отдельный KMP-модуль. Это **новый
контракт** (не замена :message-store-api/events/EventStore — тот пока жив).

**Двухуровневое хранилище событий**:
  1. :event-store (этот PR) — короткий bounded tail, авто-TTL.
     Для live SSE и recent replay (catchup после короткого disconnect).
  2. :message-store-api (MessageStore) — полный audit log, никогда не
     эвиктится. Source of truth для длинного disconnect / audit query.

**API**:
  - append(Event) — put, идемпотентный по id
  - events(after: Instant?): Flow<Event> — catchup + live в одном Flow
  - earliestEventDate(): Instant? — для gap detection у клиента

**Чего НЕТ в API** (by design):
  - delete/prune методов — TTL/cap eviction полностью на стороне impl.
    Caller'ы не должны забыть вызвать cleanup (single source of truth).
  - conversationId / type в Event — opaque payload, тип envelope'а
    решает producer.

Пока interfaces only — implementations (InMemoryEventStore, persistent)
появятся в следующих коммитах. Никаких изменений в существующем
EventStore в :message-store-api, чтобы не ломать зависимости.

KMP targets: jvm + linuxX64 + mingwX64 (Apple auto-disabled на Linux).

Modules:
  + :event-store — новый, commonMain only, ~150 строк
2026-09-20 15:38:05 +03:00
subochev 2d9ad526bb refactor(storage): split :storage-core into message-store-api + working-memory-api
ci / JVM build + tests (push) Failing after 2m5s
Разделяет монолитный :storage-core на 3 модуля с чёткими границами:

  :message-store-api   — MessageStore, ReflectionStore, EventStore, ConversationStore +
                          Content, Payload, MessageContext, Ids, MessageEvent
                          (audit log + event stream)
  :working-memory-api  — WorkingMemoryStore + WorkingMemoryEntry
                          (runtime context с compaction)
  :storage-bundle      — StorageBundle агрегатор, зависит от обоих
                          (только для server-side runtime)

Пакеты:
  pw.binom.agentik.storage.*  → УДАЛЕНО
  pw.binom.agentik.messageStore.*        — append-only API
  pw.binom.agentik.messageStore.events.* — EventStore + EventRecord
  pw.binom.agentik.workingMemory.*      — WM API
  pw.binom.agentik.storageBundle.*       — aggregator

Зачем:
  - Тонкий клиент может подтянуть ТОЛЬКО :message-store-api (~15KB, нет
    compaction-логики, нет MessageStore+WorkingMemoryStore cross-deps).
  - Android-agent в будущем подключит :message-store-api для audit log,
    серверный runtime — :storage-bundle со всем.
  - Компиляционные границы защищают от случайной зависимости от WM
    в read-only клиентах (раньше один :storage-core не давал такой
    гарантии).

Миграция:
  - Имплементации (:storage-inmemory, :storage-sqlite, :storage-ksqlite)
    обновили package + добавили deps на оба API модуля + :storage-bundle.
  - Тесты из :storage-core (PersistenceTest, SqliteStoresMigrationTest,
    TokenStatsTest) переехали в :standalone, получили testImplementation
    на оба API модуля и импорты новых типов.
  - 52 файла в :standalone, :agent-toolsets, :llm-tools, :server, :client,
    :agentik-cli обновили FQN.
  - :storage-core удалён.

Совместимость схем не меняется — все 5 impl'ов (3 backend × 5 store) хранят
данные в тех же таблицах, миграция между Sqlite и Ksqlite возможна через SQL dump.

Тесты:
  standalone         178 ✅
  agent-toolsets      36 ✅
  storage-inmemory    47 ✅
  storage-sqlite      17 ✅  (включая переехавшие persistence/* + tokenStats)
  storage-ksqlite     36 ✅
  ---
  Total: 314 tests, 0 failures
2026-09-20 15:02:54 +03:00
subochev dd7aec8df1 fix(storage-ksqlite): deadlock in ConversationStore.delete/rename
ci / JVM build + tests (push) Failing after 1m16s
mutex в kotlinx.coroutines НЕ reentrant — при вызове get() изнутри withLock
получаем deadlock. ConversationStore.delete() и rename() использовали именно
этот паттерн для проверки существования.

Fixed: заменил на raw SELECT 1 FROM conversation WHERE id=? и
SELECT updated_at FROM conversation WHERE id=? — те же проверки, без
повторного взятия mutex.

Discovered by full-test-suite run: 3 tests in KsqliteConversationStoreTest
были в UncompletedCoroutinesError (UncompletedCoroutinesError после 1 минуты
ожидания), хотя отдельный прогон EventStore (где нет вызовов get() внутри
mutex) проходил. После фикса все 36 тестов проходят:
  - KsqliteEventStoreTest         (11)
  - KsqliteConversationStoreTest  (9)
  - KsqliteMessageStoreTest       (5)
  - KsqliteWorkingMemoryStoreTest (5)
  - KsqliteReflectionStoreTest    (6)
2026-09-20 14:38:48 +03:00
subochev bf2649a856 feat(storage-ksqlite): migrate all 5 stores to ksqlite backend
ci / JVM build + tests (push) Failing after 1m21s
Расширяет :storage-ksqlite (ранее только EventStore) — все 5 store'ов из
:storage-core теперь имеют ksqlite-имплементацию с теми же контрактами:

  - KsqliteConversationStore (CRUD диалогов, каскадный delete messages+WM)
  - KsqliteMessageStore (audit log, listAll + tokenStats через encode/decode)
  - KsqliteWorkingMemoryStore (compaction с транзакцией, max order_idx)
  - KsqliteReflectionStore (insert/listRecent/listForConversation/deleteOlderThan + events flow)
  - KsqliteEventStore (replay-after-disconnect, INSERT OR REPLACE)

Схемы таблиц полностью идентичны :storage-sqlite (conversation, message,
working_memory, reflection, agent_event) — данные совместимы между двумя
backend'ами, можно мигрировать через SQL dump.

MessageCodecs.kt — hand-rolled encode/decode для MessageRecord ↔ payload_json.
Скопирован из :storage-sqlite где helpers были private; в :storage-ksqlite
свой набор, синхронизация — ответственность разработчика (см. KDoc).

KsqliteStores.kt — фабрика open(path) / inMemory(name), возвращает bundle
из 5 store'ов + SQLiteConnection. Аналог SqliteStores.open/inMemory.

Encoding helpers (CallPayload/ResultPayload/ErrorPayload, encodeStringArray
для Reflection.weakSpots) продублированы — alternative это вынести в
:storage-core, но это пока YAGNI.

Тесты:
  - KsqliteEventStoreTest         — 11 tests ✅ (passes на JVM и linuxX64)
  - KsqliteConversationStoreTest —  9 tests ✅
  - KsqliteMessageStoreTest      —  5 tests ✅
  - KsqliteWorkingMemoryStoreTest — 5 tests ✅
  - KsqliteReflectionStoreTest   —  6 tests ✅

Build verified: компилируется на JVM и linuxX64. nativeMain-deps
(kotlin-logging) перенесены в jvmMain т.к. KMP-артефакта нет.

Известные проблемы:
  - gradle test runner иногда не финализирует XML-результаты на Linux FS
    (in-progress-results-generic*.bin остаются). Тесты при этом проходят
    (видно в отчёте build/reports/tests/jvmTest/*.html), но счётчик
    tests="N" в XML не аггрегируется.
  - Storage bundle в KsqliteStores возвращает StorageBundle (KMP),
    но стандартный app wiring пока не подключает его — :standalone
    использует SqliteStores. Подключение = следующий шаг.
2026-09-20 14:33:38 +03:00
subochev 4ad59d5f5d feat(events): EventStore + AllEvent unified stream + replay endpoints
ci / JVM build + tests (push) Has been cancelled
release / Publish KMP libraries → caffeine Nexus (release) Successful in 32s
EventStore (persistent event log) и AllEvent (sealed wrapper для
третьего типа подписки — ВСЕ events в одном потоке). Touches 7 modules.

Архитектура:
  Producer (ChatAgent + ConversationEvents) → EventStore + SharedFlow
  ↓                                            ↓
  Live SSE (cold, no replay)         Replay endpoints (cursor-based)

(1) :storage-core — EventStore interface
  - append(record): idempotent по record.id (INSERT OR IGNORE)
  - query(conversationId?, afterId?, limit): пагинированный catchup
  - pruneOlderThan(instant): TTL cleanup
  - count(): maintenance метрика
  - @Serializable EventRecord(id, conversationId?, createdAt, type, payload)
  - enum EventType: AGENT_*/CONVERSATION_* (forward-compat fallback)
  - StorageBundle дополнен eventStore: EventStore? = null (backward-compat)

(2) :storage-inmemory — InMemoryEventStore
  - Thread-safe (Mutex), binarySearch для упорядоченной вставки
  - Записи сортируются по createdAt ASC, ties по id ASC (стабильно)
  - Idempotency по id (повторный append no-op)

(3) :storage-sqlite — SqliteEventStore
  - sqldelight schema: agent_event (id PK, conversation_id?, created_at,
    type, payload BLOB) + 2 индекса (conversation_id+created_at,
    created_at)
  - Миграция v3: CREATE TABLE IF NOT EXISTS (additive)
  - 5 запросов: insert, queryGlobal, queryByConv, pruneOlderThan, count
  - Forward-compat: неизвестный EventType в БД → fallback AGENT_CREATED
    (чтобы старые клиенты не падали на новых enum values)
  - Добавлен в SqliteStores (open/inMemory + asBundle())

(4) :standalone — Producer wiring
  - ChatAgent.persistAgentEvent() — fire-and-forget append при каждом
    AgentEvent (Created/Deleted/Renamed)
  - ConversationEvents — персистит в EventStore при каждом tryEmit/emit
    (концертный случай от connect disconnect)
  - ChatAgent.allEvents() — merge agent-events + snapshot всех живых
    диалогов в единый Flow<AllEvent>

(5) :proto — AllEvent sealed interface
  - AllEvent.Agent(date, event: AgentEvent)
  - AllEvent.Conversation(date, conversationId, event: Event)
  - Agent.allEvents(after): Flow<AllEvent> — третий тип подписки
    (в дополнение к events() и Conversation.events)

(6) :server — Endpoints
  - GET /events/all — SSE поток AllEvent (cold)
  - GET /events/replay?after_id=&limit= — пагинированный catchup
    (503 если EventStore не сконфигурирован)
  - GET /conversations/{id}/events/replay?after_id=&limit= — то же per-conv
  - Module.kt принимает eventStore: EventStore? параметром

(7) :client — Client API
  - AgentClient.allEvents(after) — подписка на /events/all SSE
  - AgentClient.replayAllEvents(afterId, limit) — catchup /events/replay
  - AgentClient.replayConversationEvents(convId, afterId, limit)
  - EventRecordDto — wire-зеркало EventRecord (клиент не зависит
    от :storage-core, определяет DTO локально; формат совместим с
    серверным JSON)

Тесты: 22 новых теста (12 InMemory + 10 Sqlite), все зелёные.
Все три слоя синхронизированы: proto contract + standalone impl +
server endpoint + client API.
2026-09-20 02:51:58 +03:00
Porfiry c140d0b758 client: выпилен CIO — движок приходит от потребителя
ci / JVM build + tests (push) Successful in 6m14s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 33s
- :client больше не создаёт HttpClient: нет зависимости на ktor-client-cio,
  нет defaultAgentikHttpClient.
- applyAgentikDefaults(token) — конфигурация agentik (JSON + Bearer) поверх клиента.
- agentikHttpClient(engineFactory, token, configure) — сборка клиента из фабрики
  движка потребителя.
- AgentikAgent(id, baseUrl, httpClient) — клиент обязателен, параметр token убран.
- :agentik-cli получил свой defaultCliHttpClient() (CIO + requestTimeout=0);
  8 команд передают клиент явно.
2026-09-19 22:41:34 +03:00
208 changed files with 7819 additions and 2662 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 структура)
+37
View File
@@ -0,0 +1,37 @@
Status of message-log-api migration:
DONE:
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
- Content.kt (sealed: Text, Image)
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
- MessageStore.kt (interface, TokenStats, MessageEvent)
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
6. Wrote /tmp/run_rename.sh that runs the python script.
PENDING:
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
- Verify build compiles (run standalone tests).
Files that need import updates (per search):
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
- standalone/src/jvmMain/.../ToolDispatcher.kt
- standalone/src/jvmMain/.../ConversationLoop.kt
- standalone/src/jvmTest/.../ChatAgentTest.kt
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
Workaround needed before continuing the migration.
+1 -1
View File
@@ -77,7 +77,7 @@ budget exhaustion, registry filter, parallel dispatch.
## Чего здесь НЕТ ## Чего здесь НЕТ
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM. - Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore` - Никакого persistent storage. Опирается на контракт `ContextStore`
(см. `:storage-core`). (см. `:storage-core`).
## Текущий статус ## Текущий статус
+3 -2
View File
@@ -21,8 +21,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// :storage-core — для StorageBundle в ToolsetContext (commit 5+) api(project(":journal-api"))
api(project(":storage-core")) api(project(":reflection-api"))
api(project(":context-api"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke) // litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api) api(libs.litert.api)
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
* Контекст, который тулсеты получают при активации. * Контекст, который тулсеты получают при активации.
* *
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда * В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета * добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
* могли читать/писать сообщения и память. * могли читать/писать сообщения и память.
* *
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой * Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
+1
View File
@@ -41,6 +41,7 @@ kotlin {
implementation(libs.kotlinx.cli) implementation(libs.kotlinx.cli)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio)
} }
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения): // :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
// весь код, включая platformEnv, лежит в commonMain. // весь код, включая platformEnv, лежит в commonMain.
@@ -0,0 +1,24 @@
package pw.binom.agentik.cli
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import pw.binom.agentik.client.applyAgentikDefaults
/**
* HTTP-клиент CLI: движок CIO + конфигурация agentik.
*
* Движок выбирается здесь, а не в `:client`: библиотека не выбирает транспорт за
* потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64)
* покрываются CIO.
*
* `requestTimeout = 0` — отключение встроенного request-таймаута CIO;
* defense-in-depth против обрыва долгих SSE-idle (основная защита —
* `noSseReadTimeout` в `:client`).
*
* [token] = `null` — авторизация выключена.
*/
internal fun defaultCliHttpClient(token: String? = null): HttpClient =
HttpClient(CIO) {
applyAgentikDefaults(token)
engine { requestTimeout = 0 }
}
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") { class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
val id by argument(ArgType.String, description = "ID диалога") val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val ok = agent.deleteConversation(id) val ok = agent.deleteConversation(id)
if (ok) println("deleted: $id") else println("conversation not found: $id") if (ok) println("deleted: $id") else println("conversation not found: $id")
} }
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import kotlinx.cli.default import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
@@ -10,7 +11,7 @@ class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов а
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE) val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE)) val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
if (convs.isEmpty()) { if (convs.isEmpty()) {
println("(no conversations)") println("(no conversations)")
@@ -3,13 +3,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import kotlinx.cli.default import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") { class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false) val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.createConversation(temp = temp) val conv = agent.createConversation(temp = temp)
println(conv.id) println(conv.id)
} }
@@ -2,6 +2,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") { class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
@@ -9,7 +10,7 @@ class ConvRenameSubcommand : ConvSubcommand("rename", "Переименоват
val title by argument(ArgType.String, description = "Новое название") val title by argument(ArgType.String, description = "Новое название")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") { class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
val id by argument(ArgType.String, description = "ID диалога") val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") { class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
val id by argument(ArgType.String, description = "ID диалога") val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import kotlinx.cli.default import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
@@ -13,7 +14,7 @@ class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообще
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100) val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -7,9 +7,10 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant import kotlin.time.Instant
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") { class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
@@ -17,7 +18,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg() val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -26,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
// Подписываемся на поток событий ДО send: события, отправленные // Подписываемся на поток событий ДО send: события, отправленные
// до подписки, не реплеятся (shared-flow без replay). // до подписки, не реплеятся (shared-flow без replay).
val eventsJob = launch { val eventsJob = launch {
conv.events(Instant.DISTANT_PAST) agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
// onEach печатает и терминальный event, takeWhile лишь // onEach печатает и терминальный event, takeWhile лишь
// завершает сбор после него. // завершает сбор после него.
.map { it.event }
.onEach { ev -> emit(ev) } .onEach { ev -> emit(ev) }
.takeWhile { ev -> !isTerminal(ev) } .takeWhile { ev -> !isTerminal(ev) }
.collect { } .collect { }
@@ -52,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
is Event.AppendText -> println("event AppendText ${escape(ev.body)}") is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>") is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}") is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}") is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
is Event.End -> println("event End") is Event.End -> println("event End")
is Event.Interrupted -> println("event Interrupted") is Event.Interrupted -> println("event Interrupted")
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}") is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
@@ -7,7 +7,7 @@ import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -46,7 +46,11 @@ internal class TuiBackend(
state.postSystem("подключено к ${state.config.server}") state.postSystem("подключено к ${state.config.server}")
scope.launch { scope.launch {
try { try {
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ } // agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
// распаковываем .event для получения AgentEvent (раньше был
// отдельный метод agent.events(), теперь упразднён — события
// живут в outbox-сущности).
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
} catch (_: kotlinx.coroutines.CancellationException) { } catch (_: kotlinx.coroutines.CancellationException) {
// штатная отмена при закрытии UI // штатная отмена при закрытии UI
} catch (e: Exception) { } catch (e: Exception) {
@@ -88,12 +92,12 @@ internal class TuiBackend(
} }
/** /**
* Подписывается на [Conversation.events] и перенаправляет их в [state]. * Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
*/ */
private fun subscribeEvents(conv: Conversation, from: Instant) { private fun subscribeEvents(conv: Conversation, from: Instant) {
eventsJob?.cancel() eventsJob?.cancel()
eventsJob = scope.launch { eventsJob = scope.launch {
conv.events(from).collect { ev -> dispatch(ev) } agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
} }
} }
@@ -3,11 +3,13 @@ package pw.binom.agentik.tui
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -25,6 +27,19 @@ internal class FakeAgent(
private set private set
val conversations = mutableListOf<FakeConversation>() val conversations = mutableListOf<FakeConversation>()
// Storage handles не используются тестами TuiBackend — тесты проверяют
// маршрутизацию Conversation.events в UI state. Outbox stub-ы возвращают
// emptyFlow, journal — error-on-access (никто не должен его трогать).
override val journal: JournalStore = error("journal not used in TuiBackend tests")
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {}
}
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests")
override fun createConversation(temp: Boolean): Conversation { override fun createConversation(temp: Boolean): Conversation {
createCount++ createCount++
val c = conversationFactory() val c = conversationFactory()
@@ -38,10 +53,7 @@ internal class FakeAgent(
override suspend fun deleteConversation(id: String): Boolean = override suspend fun deleteConversation(id: String): Boolean =
conversations.removeAll { it.id == id } conversations.removeAll { it.id == id }
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = override suspend fun renameConversation(id: String, title: String?): Instant? = null
conversations.toList()
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
} }
/** /**
@@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@@ -170,7 +170,7 @@ class TuiBackendTest {
runCurrent() runCurrent()
val now = kotlin.time.Clock.System.now() val now = kotlin.time.Clock.System.now()
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}""")) conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok")) conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
runCurrent() runCurrent()
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>() val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
+477 -59
View File
@@ -1,101 +1,519 @@
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native) # `:client` — Ktor-клиент к `:server` (KMP, jvm + native)
## Что это Тонкий HTTP-клиент к `:server`-фасаду + локальные примитивы, чтобы
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
про HTTP, JSON, SSE и lifecycle `Conversation`.
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) + ## Что есть
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
интерфейсы `:proto`:
- `AgentikAgent(id, baseUrl)` — entry-point фабрики. - `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
- `AgentClient` — список и lifecycle диалогов. `Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
- `ConversationClient` — `send()`, `events()`, `interrupt()`, из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
`getMessages()`, `rename()`, `close()`. - `Agent`: `createConversation` / `getConversation` / `getConversations` /
- Внутренний парсер SSE → `Flow<Event>`. `deleteConversation` / `journal` / `outbox` / `close`.
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
с основным потоком событий. См. ниже.
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate, `Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
с теми же типами, что и сервер. Один и тот же клиент работает на вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
JVM, iOS, macOS, Linux, Windows.
## Где используется ## Подключение
- `:agentik-cli` — REPL.
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
## Как подключить
```kotlin ```kotlin
// build.gradle.kts // build.gradle.kts
kotlin { dependencies {
sourceSets.commonMain.dependencies { api("pw.binom.agentik:client:0.1.0")
api("pw.binom.agentik:client:0.1.0") // Движок — на твой выбор (один из):
implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native
implementation("io.ktor:ktor-client-okhttp:3.x") // JVM
implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS
// Опционально — только если будешь использовать `InMemoryJournalStore`
// как клиентский кэш. Свой `MutableJournalStore` — не нужен.
api("pw.binom.agentik:journal-inmemory:0.1.0")
}
```
## Что клиент хранит локально (persistence)
Либа **не** имеет `SettingsRepository` / `Config` — это намеренно: UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain, Android DataStore, NSUserDefaults, ...). Либа не навязывает формат, но клиент должен сериализовать у себя минимум:
| Поле | Что это | Где взять |
|---|---|---|
| `clientId` (параметр `id` в `AgentikAgent`) | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id — это **произвольная строка клиента**, обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется один раз при первом запуске (`UUID.randomUUID().toString()`) и сохраняется. Никогда не меняется. |
| `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
| `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
Опционально (для UX): `engineFactory` — обычно compile-time выбор по платформе (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS).
Минимальный JSON для UI, который хранит в файле:
```json
{
"clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
"baseUrl": "https://agent.example.com/agentik",
"token": "s3cret"
}
```
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере.
## Быстрый старт: свой клиент за 5 минут
Один self-contained пример: создаём агента, открываем диалог,
отправляем сообщение, печатаем streaming-ответ.
```kotlin
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.runBlocking
import kotlin.time.Clock
fun main() = runBlocking {
// 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
val agent = AgentikAgent(
id = "my-client",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
token = "s3cret", // или null, если не нужен
)
// 2. Открыть диалог, отправить сообщение.
val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет")))
// 3. Собирать streaming-ответ.
conv.events(after = Clock.System.now()).collect { ev ->
when (ev) {
is Event.StartResponse -> println("[start]")
is Event.AppendText -> print(ev.body)
is Event.End -> println("[end]")
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit
}
} }
// 4. Чистый shutdown.
conv.close()
agent.close()
}
```
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
### Добавить локальный кэш истории (ещё 4 строки)
```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import kotlin.time.Instant
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
val cache = InMemoryJournalStore()
// Backfill + live-refresh в одном фоне:
launch {
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
.collect { cache.append(it) }
} }
// ваш код: // История — теперь из кэша, без HTTP:
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik") val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
val conv = agent.createConversation(title = "test") history.forEach { rec ->
conv.send(listOf(Content.Text("hello"))).collect { event -> when (rec) {
when (event) { is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
is Event.AppendText -> print(event.body) is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
is Event.End -> println("\n--- end ---") is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
is Event.Error -> error("agent error: ${event.message}") is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
else -> Unit is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
} }
} }
``` ```
## Версии Шаблон "remote.listFlow → local.append" работает с любым
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
"без геморроя".
`gradle/libs.versions.toml` → `[versions] agentik-client`. ### Что вообще не нужно писать самому
Поддерживает все KMP-таргеты, что и `:proto`. - HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует
`agentikJson` и `InstantSerializer`.
- SSE-парсер — `readSse()` внутри `:client`.
- Cursor-менеджмент для `listFlow` — дефолтная имплементация в
`JournalStore.listFlow` сама пагинирует.
- Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job.
- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)`
с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает.
- Движковые настройки (requestTimeout и пр.) — `HttpClient(engineFactory) { ... }`
создаётся здесь; для нестандартных движковых настроек используй
`agentikHttpClient(engineFactory, token)` напрямую (он экспортирован).
## Примеры API ### Что нужно написать самому
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
## Базовый пример: send + collect events
```kotlin ```kotlin
// список диалогов import pw.binom.agentik.client.AgentikAgent
agent.getConversations().collect { println(it.id to it.title) } import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import io.ktor.client.engine.cio.CIO
// live-подписка на события отдельного диалога val agent = AgentikAgent(
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { } id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
// прерывание текущего хода val conv = agent.createConversation(temp = false)
conversation.interrupt() conv.send(listOf(Content.Text("Привет, расскажи про себя")))
// история conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
conversation.getMessages(offset = 0).collect { msg -> when (ev) {
when (msg) { is Event.AppendText -> print(ev.body) // streaming чанки
is Message.UserMessage -> println("user: ${msg.content}") is Event.End -> println("\n--- end ---")
is Message.AssistantMessage -> println("assistant: ${msg.content}") is Event.Error -> error("agent error: ${ev.message}")
else -> Unit else -> Unit
} }
} }
``` ```
## История с локальным кэшем
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
чтение истории — из локального кэша.
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
реализовывал интерфейс.
```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant
class ChatSession(
private val agent: pw.binom.agentik.proto.Agent,
val conversationId: String,
) : AutoCloseable {
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
// (модуль `:journal-api`).
val cache = InMemoryJournalStore()
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
private val scope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() +
kotlinx.coroutines.Dispatchers.Default,
)
init {
// 1. Backfill: забираем всю историю разговора с сервера.
scope.launch {
agent.journal.listFlow(
conversationId = conversationId,
after = Instant.DISTANT_PAST,
).collect { cache.append(it) }
}
// 2. Live: на каждом `End` хода просим у сервера новые записи.
scope.launch {
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
if (ev is Event.End) {
val newest = cache.let {
// last-seen курсор — последний createdAt в кэше
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
?: Instant.DISTANT_PAST
}
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
.forEach { cache.append(it) }
}
}
}
}
fun history() = kotlinx.coroutines.runBlocking {
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
}
override fun close() {
scope.cancel()
}
}
// Использование:
val session = ChatSession(agent, conv.id)
// История — из кэша:
session.history().forEach { rec ->
when (rec) {
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
is MessageRecord.Error -> println("error: ${rec.message}")
}
}
// Отправить новое сообщение:
session.scope.launch {
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
}
```
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
`rec is MessageRecord.UserMessage` для реплик пользователя,
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
## Кэш списка бесед
`agent.conversationStore` — read-only view поверх `conversation`-таблицы
на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
updatedAt, без `Conversation` handle и без флагов image-support).
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
моментально реагировал на создание/удаление/переименование в другой
вкладке.
Подход — тот же **«remote → local snapshot + live-events»**:
```kotlin
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.outbox.AgentEvent
import io.ktor.client.engine.cio.CIO
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш:
// remote.listFlow → local.upsert (snapshot)
// outbox.agentEvents → local.upsert / delete (live)
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
// Кэш уже наполняется в фоне, читать можно сразу:
val all = agent.conversationStore.list(0, Int.MAX_VALUE)
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") }
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched)
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev ->
when (ev) {
is AgentEvent.Created -> println("+ ${ev.conversationId}")
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}")
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})")
is AgentEvent.Deleted -> println("- ${ev.id}")
}
}
```
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
если нужен SQLite/JSON-store.
## Стриминг live-ответа
Для streaming-рендера текущего хода подписывайся на `events()` и
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
только для UI-feedback во время хода. После `End` хода запись уже
появится в кэше через refresh-блок выше.
```kotlin
import pw.binom.agentik.proto.Event
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
when (ev) {
is Event.StartResponse -> println("[start]")
is Event.AppendText -> print(ev.body)
is Event.AppendImage -> showImage(ev.body)
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
is Event.ToolResult -> println("[result]")
is Event.End -> println("[end]")
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit
}
}
```
## Прерывание хода
```kotlin
agent.getConversation(convId)!!.interrupt()
```
## Multi-conversation
Один `Agent`, много `ChatSession`:
```kotlin
val sessions = mutableMapOf<String, ChatSession>()
fun open(convId: String): ChatSession =
sessions.getOrPut(convId) { ChatSession(agent, convId) }
fun close(convId: String) {
sessions.remove(convId)?.close()
}
```
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
## Где `:client` НЕ помогает
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
отдаёт типы и потоки.
- **Персистентность кэша** — `InMemoryJournalStore` и
`InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
`MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
в `:journal-ksqlite` как образец).
- **Нестандартные движковые настройки** — для `requestTimeout`,
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
напрямую.
## Кэш списка бесед
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
в `localStore.upsert(...)`.
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
- `Deleted(id)` → `local.delete(id)`
- `Renamed(id, title)` → `local.rename(id, title)`
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
клиент видит их через SSE и применяет к локальной копии.
**Команды** (создать / переименовать / удалить) идут через `agent`:
```kotlin
// Создать новую беседу:
val conv = agent.createConversation(temp = false) // → POST /conversations
// → server эмитит Created
// → client cache получает Created
// → UI увидит её в списке
// Переименовать:
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
// → server эмитит Renamed
// → client cache обновляет title
// Удалить:
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
// → server эмитит Deleted
// → client cache удаляет запись
```
`conversationStore` доступен **только для чтения**. Это read-only projection
на серверную таблицу `conversation` (id + title + timestamps). Для активной
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
командами `agent.createConversation / deleteConversation / renameConversation`.
### Если хочется своего cache-импла
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:storage-ksqlite` как образец.
```kotlin
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ConversationRecord
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
override suspend fun delete(id: String): Boolean { /* DELETE */ }
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
override suspend fun touch(id: String, now: Instant) { /* UPDATE updatedAt */ }
override fun close() {}
}
```
## Тесты ## Тесты
``` ```
./gradlew :client:jvmTest ./gradlew :client:jvmTest
``` ```
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва, Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
401/404. 401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
reconnect / exhausted attempts → Failed / close → cancel).
## Чего здесь НЕТ ## Auto-reconnect для живого outbox
- Никакого LLM-кода. Это просто клиент. Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
- Никакого persistent state. История хранится у сервера, клиент её сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
запрашивает через `getMessages` или подписывается через `events`. Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
## Текущий статус ```kotlin
val recon = ReconnectingOutbox(
outbox = agent.outbox, // или HttpEventStore
scope = myScreenScope,
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
)
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`, scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
но клиент совместим с любым сервером, который держит wire-контракт scope.launch {
`:server`. recon.connectionStatus().collect { status ->
when (status) {
is Connecting -> ui.showBanner("connecting...")
is Connected -> ui.hideBanner()
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
is Failed -> ui.showError(status.cause)
}
}
}
// На выходе (например, navigation back):
recon.close() // отменяет background-loop, потоки терминируются
```
Два потока **независимы** — `events()` содержит только `CommonEvent`,
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
"мешающего" `Connecting`/`Disconnected` в потоке событий.
Параметры backoff (см. `BackoffPolicy`):
- `initial` / `max` — границы задержки
- `multiplier` — множитель на каждом шаге
- `jitter` — рандом-разброс (по умолчанию 20%)
- `maxAttempts` — лимит попыток; после — `Failed` + закрытие потока
Если нужен фиксированный delay для тестов — `BackoffPolicy.Fixed(10.milliseconds, attempts = 3)`.
## Известное ограничение ## Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE. terminal (TTY). Это upstream-особенность Ktor SSE.
+5 -2
View File
@@ -21,9 +21,11 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
api(project(":proto")) api(project(":proto"))
api(project(":outbox-api"))
api(project(":journal-api"))
implementation(project(":journal-inmemory"))
implementation(libs.ktor.client.core) api(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
@@ -37,6 +39,7 @@ kotlin {
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.test.host) implementation(libs.ktor.server.test.host)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.server.cio) implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.sse) implementation(libs.ktor.server.sse)
} }
@@ -4,39 +4,42 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`. * HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
* *
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend * HttpClient создаётся внутри из переданного engine и закрывается в [close].
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа *
* POST `/conversations`. Используем `runBlocking` — это одноразовая * **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам * views на серверные хранилища. Запись — только через команды
* решает, что делать. * [createConversation] / [deleteConversation] / [renameConversation].
*/ */
internal class AgentClient( internal class AgentClient(
private val httpClient: HttpClient,
private val baseUrl: String,
override val id: String, override val id: String,
private val baseUrl: String,
private val httpClient: HttpClient,
) : Agent { ) : Agent {
private val agentUrl: String = baseUrl.trimEnd('/') private val agentUrl: String = baseUrl.trimEnd('/')
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
override fun createConversation(temp: Boolean): Conversation = override fun createConversation(temp: Boolean): Conversation =
runBlocking { runBlocking {
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") { val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
@@ -58,24 +61,17 @@ internal class AgentClient(
return response.status == HttpStatusCode.NoContent return response.status == HttpStatusCode.NoContent
} }
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> { override suspend fun renameConversation(id: String, title: String?): Instant? {
val snapshots = httpClient.get("$agentUrl/conversations") { val response = httpClient.patch("$agentUrl/conversations/$id") {
parameter("offset", offset) contentType(ContentType.Application.Json)
parameter("limit", limit) setBody(RequestRename(title))
}.body<List<ConversationSnapshot>>() }
return snapshots.map { ConversationClient(httpClient, agentUrl, it) } if (response.status == HttpStatusCode.NotFound) return null
val rec = response.body<pw.binom.agentik.journal.ConversationRecord>()
return rec.updatedAt
} }
override fun events(after: Instant): Flow<AgentEvent> = flow { override fun close() {
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() } httpClient.close()
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
}
} }
} }
@@ -1,47 +1,173 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngineFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import kotlin.time.Instant
/** /**
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent` * Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
* (модуль `:server`). *
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
* снаружи — всё под капотом.
* *
* ``` * ```
* val client = AgentikAgent( * val agent = AgentikAgent(
* id = "my-agent", * id = "my-client",
* baseUrl = "http://localhost:8080/agentik", * baseUrl = "http://localhost:8080/agentik",
* engineFactory = CIO,
* token = "s3cret",
* ) * )
* val conv = client.createConversation(temp = false) * val conv = agent.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi"))) * conv.send(listOf(Content.Text("hi")))
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... } * agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
* .map { it.event }
* .collect { ... }
* agent.close() // закрывает HttpClient + локальный кэш
* ``` * ```
* *
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность * ## Что клиент должен хранить локально (persistence)
* агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига).
* *
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый * Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой. * UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain,
* `SharedPreferences`, Android DataStore, NSUserDefaults, ...). Либа
* не навязывает формат, но вот минимальный набор, который клиент должен
* сериализовать у себя, чтобы пережить перезапуск:
*
* | Поле | Что это | Где взять |
* |---|---|---|
* | `id` | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id, а произвольная строка клиента — обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется клиентом при первом запуске, сохраняется локально |
* | `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
* | `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
*
* Опционально (для UX):
* | Поле | Зачем |
* |---|---|
* | `engineFactory` | Зависит от платформы (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS). Выбор — обычно compile-time. |
*
* Пример минимального persistence-файла (для UI, который хранит JSON):
*
* ```json
* {
* "clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
* "baseUrl": "https://agent.example.com/agentik",
* "token": "s3cret"
* }
* ```
*
* `clientId` генерируется один раз при первой установке (`UUID.randomUUID().toString()`)
* и больше не меняется — иначе сломается log multiplexing на сервере.
*
* ## Локальный кэш списка бесед
*
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
* Внутри лежит [InMemoryMutableConversationStore], который:
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`.
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
* соответствующий `upsert/delete/rename/touch` к локальной копии.
*
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
* через [Agent] и **не** через `conversationStore` (он read-only).
*
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
* HttpClient + локальный кэш + background-coroutine (идемпотентно).
* После этого `createConversation` / `getConversation` etc. не определены.
*/ */
fun AgentikAgent( fun AgentikAgent(
id: String, id: String,
baseUrl: String, baseUrl: String,
engineFactory: HttpClientEngineFactory<*>,
token: String? = null, token: String? = null,
httpClient: HttpClient = defaultAgentikHttpClient(token), ): Agent {
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient)
return wrapWithLocalConversationCache(client, scopeClient = client)
}
/** /**
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse]) * Оборачивает [Agent] так, что [Agent.conversationStore] становится
* живёт в общем коде и плагина `SSEClientContent` не требует. * локальным in-memory кэшем, синхронизированным с удалённым стором
* через outbox-события.
* *
* **Платформы:** * - **Seed**: при создании делает один snapshot через
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный * `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует * - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в * `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
* `ConversationClient.events`/`AgentClient.events` уже ставят
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
* *
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw). * Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
* Реализация — в [HttpClientFactory.kt]. * (на read-only projection локального [InMemoryMutableConversationStore]).
* Остальные методы [Agent] — delegated в [delegate].
*/ */
private fun wrapWithLocalConversationCache(
delegate: Agent,
scopeClient: Agent,
): Agent = object : Agent by delegate {
private val localStore: MutableConversationStore = InMemoryMutableConversationStore()
private val cacheScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val syncJob: Job
init {
// Делаем cacheStore read-only view на localStore.
// (Через вложенный класс — см. ниже.)
// Запускаем seed + live-refresh параллельно.
syncJob = cacheScope.launch {
// 1. seed — snapshot всех текущих бесед с сервера
try {
delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE)
.collect { rec -> localStore.upsert(rec) }
} catch (_: Throwable) {
// seed может упасть (offline / 5xx) — не критично,
// live-источник всё равно догонит при первом событии.
}
// 2. live — применяем outbox-события.
// Используем `first()` для knownId после Created — потом отписываемся,
// потому что Created нужно вытянуть полный record через `remote.get(id)`.
// Renamed/Touched меняют локальную копию без round-trip.
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce ->
when (val ev = ce.event) {
is AgentEvent.Created -> {
// Created не несёт title/timestamps — нужно сходить в remote.
val rec = delegate.conversationStore.get(ev.conversationId)
if (rec != null) localStore.upsert(rec)
}
is AgentEvent.Deleted -> localStore.delete(ev.id)
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
}
}
}
}
/**
* Read-only projection локального кэша — клиент через него только
* читает (`get` / `list` / `listFlow`).
*/
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
override fun close() {} // owned by outer close
}
override fun close() {
cacheScope.cancel()
runBlocking { syncJob.join() }
delegate.close()
}
}
@@ -6,18 +6,12 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.patch import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -72,22 +66,6 @@ internal class ConversationClient(
httpClient.post("$convUrl/interrupt") httpClient.post("$convUrl/interrupt")
} }
override fun events(after: Instant): Flow<Event> = flow {
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
}
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
httpClient.get("$convUrl/messages") { httpClient.get("$convUrl/messages") {
parameter("after", after.toString()) parameter("after", after.toString())
@@ -23,4 +23,4 @@ data class ConversationSnapshot(
internal data class RequestCreateConversation(val temp: Boolean) internal data class RequestCreateConversation(val temp: Boolean)
@Serializable @Serializable
internal data class RequestRename(val title: String) internal data class RequestRename(val title: String?)
@@ -1,7 +1,7 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header import io.ktor.client.request.header
@@ -9,19 +9,20 @@ import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
/** /**
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli). * Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
* *
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE: * Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]). * не нужен — он передаёт engine в [AgentikAgent] и получает готовый
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
* на [pw.binom.agentik.proto.Agent.close].
* *
* При заданном [token] на ВСЕ запросы клиента навешивается * Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
* `Authorization: Bearer <token>` через плагин [DefaultRequest]. Это накрывает * (например, дополнительные нестандартные запросы в обход `Agent` API).
* все 10 REST-вызовов и оба SSE-потока сразу — заголовок живёт на HTTP-клиенте,
* а не в отдельных запросах.
*/ */
fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO) { fun agentikHttpClient(
engine { requestTimeout = 0 } engineFactory: HttpClientEngineFactory<*>,
token: String? = null,
): HttpClient = HttpClient(engineFactory) {
install(ContentNegotiation) { json(agentikJson) } install(ContentNegotiation) { json(agentikJson) }
if (token != null) { if (token != null) {
install(DefaultRequest) { install(DefaultRequest) {
@@ -0,0 +1,58 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.HttpStatusCode
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
/**
* HTTP-реализация [ConversationStore] (read-only metadata view),
* ходящая в `:server`-фасад.
*
* **Endpoint**: `GET {baseUrl}/conversations?offset=&limit=` —
* возвращает `List<ConversationRecord>` (id, title, isTemporal, createdAt,
* updatedAt) БЕЗ handle'ов и image-support флагов (это лёгкая проекция
* для UI-списка; handle берётся через `agent.getConversation(id)`).
*
* **Read-only**: запись в `conversation` table — только через команды
* `agent.createConversation / deleteConversation / renameConversation`.
*
* Клиентский кэш строится композицией `HttpConversationStore` (snapshot)
* + `agent.outbox.agentEvents(after)` (live deltas: Created/Deleted/
* Renamed/Touched) — см. `client/README.md` секция
* «Кэш списка бесед».
*/
internal class HttpConversationStore(
private val httpClient: HttpClient,
private val baseUrl: String,
) : ConversationStore {
private val agentUrl: String = baseUrl.trimEnd('/')
override suspend fun get(id: String): ConversationRecord? {
val response = httpClient.get("$agentUrl/conversations/$id")
if (response.status == HttpStatusCode.NotFound) return null
check(response.status == HttpStatusCode.OK) {
"conversationStore.get($id): server returned ${response.status}"
}
return response.body<ConversationRecord>()
}
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> {
val response = httpClient.get("$agentUrl/conversations") {
parameter("offset", offset)
parameter("limit", limit)
}
check(response.status == HttpStatusCode.OK) {
"conversationStore.list: server returned ${response.status}"
}
return response.body<List<ConversationRecord>>()
}
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@@ -0,0 +1,132 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import kotlin.time.Clock
import kotlin.time.Instant
/**
* HTTP-реализация [OutboxStore] (= [pw.binom.agentik.outbox.OutboxStore]),
* ходящая в `:server`-фасад.
*
* **Endpoint-раскладка** (новый дизайн — storage handles на [Agent]):
* - [events] → `GET {baseUrl}/outbox/events?after=` (полный поток
* [CommonEvent], bounded-tail + live SSE, см. [pw.binom.agentik.server.outboxRoutes])
* - [agentEvents] → `GET {baseUrl}/events?after=` (legacy proto-роут:
* сервер пробрасывает [pw.binom.agentik.outbox.agentEvents] и распаковывает
* `.event` для обратной совместимости с форматом AgentEvent)
* - [conversationEvents] с `conversationId != null` → `GET /conversations/{id}/events`
*
* Для [conversationEvents] с `conversationId == null` (события всех диалогов)
* fallback на default [OutboxStore.conversationEvents] — общий поток
* `/outbox/events` + filter. Это редкий кейс (admin-дашборды), и
* оптимизировать его отдельно нерационально.
*
* [earliestEventDate] не имеет своего endpoint'а; возвращает `Clock.System.now()`
* (см. KDoc [OutboxStore.earliestEventDate] — для пустого буфера это и есть
* контрактное значение). Клиент, который полагался на gap detection через
* message store, продолжит работать — просто fallback никогда не сработает.
*
* **Импорты [CommonEvent]/[AgentEvent]/[Event] идут напрямую из
* `pw.binom.agentik.outbox`** — typealias'ы в `:proto.CommonEvent` и т.п.
* НЕ поддерживают nested-class access (`CommonEvent.Agent` через alias
* даёт "Unresolved qualified name"), поэтому приходится использовать
* конкретный пакет. Типы идентичны, alias только для удобства внешнего API.
*/
internal class HttpEventStore(
private val httpClient: HttpClient,
private val baseUrl: String,
) : OutboxStore {
private val agentUrl: String = baseUrl.trimEnd('/')
override fun events(after: Instant?): Flow<CommonEvent> = flow {
val url = buildString {
append("$agentUrl/outbox/events")
if (after != null) append("?after=$after")
}
httpClient.prepareGet(url) { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(CommonEvent.serializer(), payload))
}
}
}
/**
* Override: идём в `/events` напрямую — сервер фильтрует только lifecycle-события.
* Default из [EventStore.agentEvents] читал бы `/events/all` + `filterIsInstance`.
*/
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = flow {
val url = buildString {
append("$agentUrl/events")
if (after != null) append("?after=$after")
}
httpClient.prepareGet(url) { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"agentEvents: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
val event = agentikJson.decodeFromString(AgentEvent.serializer(), payload)
emit(CommonEvent.Agent(date = event.date, event = event))
}
}
}
/**
* Override с `conversationId != null` — идём в `/conversations/{id}/events`.
* С `null` (события всех диалогов) — fallback на default impl из [EventStore]:
* общий `/events/all` + filter.
*/
override fun conversationEvents(
after: Instant?,
conversationId: String?,
): Flow<CommonEvent.Conversation> {
if (conversationId == null) {
return super.conversationEvents(after, null)
}
return flow {
val url = buildString {
append("$agentUrl/conversations/$conversationId/events")
if (after != null) append("?after=$after")
}
httpClient.prepareGet(url) { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"conversationEvents: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
val event = agentikJson.decodeFromString(Event.serializer(), payload)
emit(CommonEvent.Conversation(date = event.date, conversationId = conversationId, event = event))
}
}
}
}
/**
* У HTTP-варианта нет своего endpoint'а для earliest-event-date.
* Контракт [EventStore.earliestEventDate] для пустого буфера говорит
* "сейчас" — для HTTP-клиента буфер на нашей стороне всегда "пуст"
* (мы не держим своё состояние), поэтому возвращаем `Clock.System.now()`.
*/
override suspend fun earliestEventDate(): Instant = Clock.System.now()
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@@ -0,0 +1,60 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.HttpStatusCode
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Instant
/**
* HTTP-реализация [JournalStore] (append-only audit log сообщений диалога),
* ходящая в `:server`-фасад.
*
* **Endpoint**: `GET {baseUrl}/journal/conversations/{id}/messages?after=&offset=&limit=`
* (см. [pw.binom.agentik.server.journalRoutes]).
*
* Возвращает raw [MessageRecord] (все типы: UserMessage / AssistantMessage /
* ToolCall / ToolResult / Error). В отличие от `GET /conversations/{id}/messages`
* в `:server`'s proto-роутах (который отдаёт project'нутые
* [pw.binom.agentik.proto.Message]), здесь клиент получает полный transcript
* с tool-call/tool-result/error payload'ами, turn-tokens и context'ом.
*
* **listFlow** — default cold-flow paging через [list] (N+1 round-trip,
* дефолтная реализация из [JournalStore]). Для remote/SQL-backed store'а
* это OK: server-side paging + client-side flow compose'ится естественно.
*
* **Read-only**: [JournalStore] не имеет `append` — запись только через
* writer-референс, который ChatAgent держит внутри (тип
* `MutableJournalStore`, не выставлен наружу через [pw.binom.agentik.proto.Agent]).
*/
internal class HttpJournalStore(
private val httpClient: HttpClient,
private val baseUrl: String,
) : JournalStore {
private val agentUrl: String = baseUrl.trimEnd('/')
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> {
val response = httpClient.get("$agentUrl/journal/conversations/$conversationId/messages") {
parameter("after", after.toString())
parameter("offset", offset)
parameter("limit", limit)
}
check(response.status == HttpStatusCode.OK) {
"journal.list: server returned ${response.status}"
}
return response.body<List<MessageRecord>>()
}
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@@ -0,0 +1,242 @@
package pw.binom.agentik.client
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.math.min
import kotlin.math.pow
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Состояние подключения к удалённому [OutboxStore]. Эмитится через
* [ReconnectingOutbox.connectionStatus] — отдельным потоком, **не**
* смешивается с [ReconnectingOutbox.events].
*
* Типичный цикл:
* ```
* Connecting(1) → Connected → ... → Disconnected(reason, retryIn) →
* Connecting(2) → Connected → ...
* ```
* При полном исчерпании попыток ([BackoffPolicy.maxAttempts]) —
* финальный [Failed].
*/
sealed interface ConnectionStatus {
/** Начата попытка подключения (включая первую — `attempt == 1`). */
data class Connecting(val attempt: Int) : ConnectionStatus
/** Получен первый event с сервера после [Connecting] / [Disconnected]. */
data class Connected(val since: Instant) : ConnectionStatus
/**
* Стрим оборвался (network error, server close, таймаут). [reason] —
* причина, `null` если штатное завершение. [willRetryIn] — через сколько
* будет следующая попытка (`null` если [Failed]).
*/
data class Disconnected(
val reason: Throwable?,
val willRetryIn: Duration?,
) : ConnectionStatus
/**
* Все попытки исчерпаны ([BackoffPolicy.maxAttempts]). Поток [events]
* закрывается после этого. Создатель [ReconnectingOutbox] должен
* решить, что делать — показать ошибку пользователю, пересоздать
* outbox и т.п.
*/
data class Failed(val cause: Throwable) : ConnectionStatus
}
/**
* Политика backoff для [ReconnectingOutbox]. Параметры:
*
* - [initial] — задержка перед первой retry-попыткой.
* - [max] — потолок задержки (после серии умножений).
* - [multiplier] — множитель на каждом шаге (например, `2.0` → 1s, 2s, 4s, 8s, ...).
* - [jitter] — доля случайного разброса `[0, jitter]` от текущей задержки
* (например, `0.2` = ±20%). Снижает thundering-herd при массовом reconnect.
* - [maxAttempts] — лимит попыток. `Int.MAX_VALUE` = бесконечно.
*/
data class BackoffPolicy(
val initial: Duration = 1.seconds,
val max: Duration = 30.seconds,
val multiplier: Double = 2.0,
val maxAttempts: Int = Int.MAX_VALUE,
val jitter: Double = 0.2,
) {
init {
require(initial > Duration.ZERO) { "initial must be positive" }
require(max >= initial) { "max must be >= initial" }
require(multiplier >= 1.0) { "multiplier must be >= 1.0" }
require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
require(jitter in 0.0..1.0) { "jitter must be in [0, 1]" }
}
companion object {
/** 1s → 2s → 4s → ... → 30s, jitter ±20%, бесконечные попытки. */
val Default: BackoffPolicy = BackoffPolicy()
/** Только для тестов: фиксированные задержки без разброса. */
fun Fixed(delay: Duration, attempts: Int = 3): BackoffPolicy =
BackoffPolicy(
initial = delay,
max = delay,
multiplier = 1.0,
maxAttempts = attempts,
jitter = 0.0,
)
}
}
/**
* Обёртка над [OutboxStore] с автоматическим reconnect при обрыве стрима.
*
* **Два независимых потока**:
* - [events] — `Flow<CommonEvent>`, тот же контракт что [OutboxStore.events],
* но с автоматическим переподключением через [BackoffPolicy]. Cursor
* (`lastSeen`) сохраняется между попытками — клиент не теряет события.
* - [connectionStatus] — `Flow<ConnectionStatus>`, **параллельный** поток
* lifecycle подключения. Не смешивается с [events].
*
* ```
* val outbox = ReconnectingOutbox(httpEventStore, scope)
*
* scope.launch {
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) }
* }
* scope.launch {
* outbox.connectionStatus().collect { s -> ui.showStatus(s) }
* }
*
* // На выходе:
* outbox.close() // отменяет background-loop, эмитит Cancelled-как-Disconnected
* ```
*
* Создатель передаёт свой [scope] — жизненный цикл reconnect-цикла
* привязан к нему. Закрытие scope (или явный [close]) отменяет
* background-loop. После [close] оба flow терминируются.
*/
class ReconnectingOutbox(
private val outbox: OutboxStore,
private val scope: CoroutineScope,
private val policy: BackoffPolicy = BackoffPolicy.Default,
private val random: Random = Random.Default,
) : AutoCloseable {
private val _events = MutableSharedFlow<CommonEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val _status = MutableSharedFlow<ConnectionStatus>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@OptIn(ExperimentalAtomicApi::class)
private val started = AtomicBoolean(false)
private var job: Job? = null
@OptIn(ExperimentalAtomicApi::class)
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null)
/**
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
* учитывается только при первом вызове (любом из [events] /
* [connectionStatus]). После reconnect курсор берётся из `date`
* последнего виденного события.
*
* Коллекторы независимы — каждый получает свою копию потока (shared).
* Медленный коллектор может пропускать события при переполнении буфера
* (`DROP_OLDEST`).
*/
fun events(after: Instant? = null): Flow<CommonEvent> {
ensureStarted(after)
return _events
}
/**
* Lifecycle подключения: [ConnectionStatus.Connecting] /
* [ConnectionStatus.Connected] / [ConnectionStatus.Disconnected] /
* [ConnectionStatus.Failed]. **Не смешивается** с [events] — это
* отдельный поток для UI-индикации статуса сети.
*/
fun connectionStatus(): Flow<ConnectionStatus> {
ensureStarted(null)
return _status
}
@OptIn(ExperimentalAtomicApi::class)
private fun ensureStarted(initialCursor: Instant?) {
if (!started.compareAndSet(false, true)) return
lastSeen.store(initialCursor)
job = scope.launch { runLoop() }
}
@OptIn(ExperimentalAtomicApi::class)
private suspend fun runLoop() {
var attempt = 0
var connected = false
while (currentCoroutineContext().isActive) {
attempt++
_status.emit(ConnectionStatus.Connecting(attempt))
val error: Throwable? = try {
outbox.events(after = lastSeen.load()).collect { event ->
lastSeen.store(event.date)
_events.emit(event)
if (!connected) {
connected = true
_status.emit(ConnectionStatus.Connected(event.date))
}
}
null
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
t
}
connected = false
if (attempt >= policy.maxAttempts) {
_status.emit(
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
)
return
}
val backoff = computeBackoff(attempt)
_status.emit(ConnectionStatus.Disconnected(error, backoff))
delay(backoff)
}
}
private fun computeBackoff(attempt: Int): Duration {
// attempt 1 → initial, 2 → initial * m, 3 → initial * m^2, ...
val base = (policy.initial.inWholeMilliseconds.toDouble() *
policy.multiplier.pow((attempt - 1).toDouble()))
.toLong()
val capped = min(base, policy.max.inWholeMilliseconds)
val jitterMs = (capped * policy.jitter * random.nextDouble()).toLong()
val finalMs = (capped + jitterMs).coerceAtLeast(1L)
return Duration.parse("${finalMs}ms")
}
override fun close() {
job?.cancel()
job = null
}
}
@@ -12,7 +12,7 @@ import io.ktor.client.request.HttpRequestBuilder
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным * использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет * (`HttpRequestBuilder.supportsRequestTimeout` проверяет
* `body is SSEClientContent`, а у нас тело — обычный GET без тела). * `body is SSEClientContent`, а у нас тело — обычный GET без тела).
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию * Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд. * **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
* *
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при * Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
@@ -1,5 +1,7 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
@@ -19,7 +21,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
/** /**
* Тесты клиентской части: [defaultAgentikHttpClient] с заданным `token` прикладывает * Тесты клиентской части: [agentikHttpClient] с заданным `token` прикладывает
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`, * `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
* без токена — заголовок не отправляется. * без токена — заголовок не отправляется.
* *
@@ -47,6 +49,9 @@ class BearerHeaderTest {
var token: String? = null var token: String? = null
} }
private fun clientWith(token: String?): HttpClient =
agentikHttpClient(engineFactory = CIO, token = token)
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> { private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
val server = embeddedServer(ServerCIO, port = 0) { val server = embeddedServer(ServerCIO, port = 0) {
routing { routing {
@@ -66,7 +71,7 @@ class BearerHeaderTest {
fun clientWithTokenAttachesBearerHeader() = runBlocking { fun clientWithTokenAttachesBearerHeader() = runBlocking {
val (server, port) = startServer() val (server, port) = startServer()
try { try {
val client = defaultAgentikHttpClient("secret") val client = clientWith("secret")
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.OK, resp.status) assertEquals(HttpStatusCode.OK, resp.status)
assertEquals("[]", resp.bodyAsText()) assertEquals("[]", resp.bodyAsText())
@@ -79,7 +84,7 @@ class BearerHeaderTest {
fun clientWithoutTokenGets401(): Unit = runBlocking { fun clientWithoutTokenGets401(): Unit = runBlocking {
val (server, port) = startServer() val (server, port) = startServer()
try { try {
val client = defaultAgentikHttpClient(null) val client = clientWith(null)
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.Unauthorized, resp.status) assertEquals(HttpStatusCode.Unauthorized, resp.status)
} finally { } finally {
@@ -91,7 +96,7 @@ class BearerHeaderTest {
fun clientWithWrongTokenGets401(): Unit = runBlocking { fun clientWithWrongTokenGets401(): Unit = runBlocking {
val (server, port) = startServer() val (server, port) = startServer()
try { try {
val client = defaultAgentikHttpClient("wrong") val client = clientWith("wrong")
val resp = client.get("http://127.0.0.1:$port/agentik/conversations") val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.Unauthorized, resp.status) assertEquals(HttpStatusCode.Unauthorized, resp.status)
} finally { } finally {
@@ -0,0 +1,211 @@
package pw.binom.agentik.client
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.outbox.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* In-memory [OutboxStore] для unit-тестов [ReconnectingOutbox].
*
* Управление:
* - [push] — кладёт [CommonEvent] в очередь, флоу доставит.
* - [throwAtNextEvent] — следующий «тик» `events(after)` бросит этот Throwable
* (симулирует network error / stream break).
*
* Сигнатура [events] идентична боевой — её можно подменить боевым
* `HttpEventStore`, контракт один и тот же.
*/
internal class FakeOutbox : OutboxStore {
private sealed interface Msg {
data class Ev(val event: CommonEvent) : Msg
data class Err(val throwable: Throwable) : Msg
}
private val channel = Channel<Msg>(Channel.UNLIMITED)
override fun events(after: Instant?): Flow<CommonEvent> = flow {
for (msg in channel) {
when (msg) {
is Msg.Err -> throw msg.throwable
is Msg.Ev -> emit(msg.event)
}
}
}
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow()
override fun conversationEvents(
after: Instant?,
conversationId: String?,
): Flow<CommonEvent.Conversation> = emptyFlow()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() { channel.close() }
}
private fun testEvent(dateMs: Long): CommonEvent =
CommonEvent.Conversation(
date = Instant.fromEpochMilliseconds(dateMs),
conversationId = "test",
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
)
@OptIn(ExperimentalCoroutinesApi::class)
class ReconnectingOutboxTest {
@Test
fun `first event after connect emits Connecting then Connected`() = runConnectionTest(
attempts = 5,
) { ctx ->
val fake = ctx.fake
val status = ctx.statusLog
val events = ctx.eventsLog
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, status.count { it is ConnectionStatus.Connecting && it.attempt == 1 })
assertEquals(1, status.count { it is ConnectionStatus.Connected })
assertEquals(1, events.size)
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
}
@Test
fun `disconnect mid-stream triggers retry with backoff and resumes from last seen`() =
runConnectionTest(attempts = 5) { ctx ->
val fake = ctx.fake
val status = ctx.statusLog
val events = ctx.eventsLog
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, events.size)
// Имитируем обрыв стрима после первого события.
fake.throwAtNextEvent(RuntimeException("simulated network error"))
ctx.advanceAndDrain(50)
// После Disconnected должен прийти Connecting(2), затем Connected,
// затем новые события без дубля предыдущего.
val disconnectedIndex = status.indexOfFirst { it is ConnectionStatus.Disconnected }
val connecting2Index = status.indexOfFirst {
it is ConnectionStatus.Connecting && it.attempt == 2
}
assertTrue(disconnectedIndex >= 0, "no Disconnected emitted, got: $status")
assertTrue(connecting2Index > disconnectedIndex,
"expected Connecting(2) after Disconnected, got: $status")
// Push a new event with later date — cursor preserves lastSeen.
fake.push(testEvent(2000))
ctx.advanceAndDrain(50)
assertEquals(2, events.size)
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
assertEquals(Instant.fromEpochMilliseconds(2000), events[1].date)
}
@Test
fun `exhausted attempts emits Failed and closes flow`() = runConnectionTest(
attempts = 3,
) { ctx ->
val fake = ctx.fake
// Каждая попытка connect бросает — все 3 попытки fail.
for (i in 0 until 3) {
fake.throwAtNextEvent(RuntimeException("server is dead #${i + 1}"))
ctx.advanceAndDrain(50)
}
val failed = ctx.statusLog.filterIsInstance<ConnectionStatus.Failed>().firstOrNull()
assertNotNull(failed) { "expected Failed status, got: ${ctx.statusLog}" }
assertTrue(failed.cause is RuntimeException)
assertEquals(0, ctx.eventsLog.size)
}
@Test
fun `close cancels background loop`() = runConnectionTest(
attempts = 5,
) { ctx ->
val fake = ctx.fake
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, ctx.eventsLog.size)
ctx.recon.close()
ctx.advanceAndDrain(100)
// После close запуск новых эмиссий не должен происходить.
val beforePush = ctx.eventsLog.size
fake.push(testEvent(2000))
ctx.advanceAndDrain(100)
assertEquals(beforePush, ctx.eventsLog.size)
}
private data class TestCtx(
val fake: FakeOutbox,
val recon: ReconnectingOutbox,
val statusLog: MutableList<ConnectionStatus>,
val eventsLog: MutableList<CommonEvent>,
val jobs: List<Job>,
val scope: CoroutineScope,
val advanceAndDrain: (Long) -> Unit,
)
/**
* Запускает [ReconnectingOutbox] с policy из `attempts` попыток по 10ms,
* сабскрайбит на оба потока в собирающие лист, и возвращает [TestCtx]
* с управляемым `advanceAndDrain(ms)` — прокрутить виртуальное время.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun runConnectionTest(
attempts: Int,
block: suspend (TestCtx) -> Unit,
) = runTest {
val policy = BackoffPolicy.Fixed(
delay = Duration.parse("10ms"),
attempts = attempts,
)
val fake = FakeOutbox()
val recon = ReconnectingOutbox(
outbox = fake,
scope = this,
policy = policy,
)
val statusLog = mutableListOf<ConnectionStatus>()
val eventsLog = mutableListOf<CommonEvent>()
val jobs = listOf(
launch { recon.connectionStatus().collect { statusLog.add(it) } },
launch { recon.events().collect { eventsLog.add(it) } },
)
val advanceAndDrain: (Long) -> Unit = { ms ->
if (ms > 0) advanceTimeBy(ms)
runCurrent()
}
try {
TestCtx(fake, recon, statusLog, eventsLog, jobs, this, advanceAndDrain).also { block(it) }
} finally {
recon.close()
jobs.forEach { it.cancel() }
fake.close()
}
}
}
+38
View File
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// Public API для runtime context агента (compaction, order_idx, summary entries).
// Зависит от :journal-api для типов `Content` / `MessageContext` (audit-log
// payload'ы, которые рабочая память ссылает).
//
// НЕ нужен тонким клиентам — только серверному рантайму (`:standalone`, `:agentik-cli`,
// будущий `:android-agent` core).
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":journal-api"))
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage package pw.binom.agentik.context
import kotlin.time.Instant import kotlin.time.Instant
@@ -7,7 +7,7 @@ import kotlin.time.Instant
* *
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row. * Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
* Агент не должен с этим типом работать напрямую — он работает с * Агент не должен с этим типом работать напрямую — он работает с
* [WorkingMemoryEntry] через [WorkingMemoryStore]. * [WorkingMemoryEntry] через [ContextStore].
*/ */
data class WorkingMemoryRow( data class WorkingMemoryRow(
val id: String, val id: String,
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
* *
* Суммаризация / чистка — один атомарный вызов [compact]. * Суммаризация / чистка — один атомарный вызов [compact].
*/ */
interface WorkingMemoryStore : AutoCloseable { interface ContextStore : AutoCloseable {
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */ /** Добавить запись в конец working memory (новый максимальный `order_idx`). */
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
@@ -1,7 +1,9 @@
package pw.binom.agentik.storage package pw.binom.agentik.context
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageContext
/** /**
* Запись в working memory диалога: ровно то, что агент сейчас видит в * Запись в working memory диалога: ровно то, что агент сейчас видит в
+43
View File
@@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// KMP-реализация :context-api (ContextStore) поверх ksqlite.
// Минимальная — только таблица `working_memory` + 2 индекса по ней.
// Остальные таблицы (`conversation`, `message`, `reflection`) живут в
// других ksqlite-модулях; этот модуль не претендует на полную схему
// агента.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json)
api(project(":context-api"))
api(project(":journal-api"))
api(project(":reflection-api"))
// :context-api ссылается на Content / MessageContext из
// :message-log-api (старый canonical). Транзитивно через api,
// но фиксируем явно чтобы тестовый код видел Content без
// обхода через :context-api.
// api(project(":message-log-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,200 @@
package pw.binom.agentik.context.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.WorkingMemoryRow
import pw.binom.agentik.journal.Ids
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
*
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
* из `:storage-ksqlite`, но:
* - лежит в собственном модуле `:context-ksqlite`;
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
* теперь главный класс — `ContextStore`); сами типы строк
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
*/
class KsqliteContextStore(
private val connection: SQLiteConnection,
) : ContextStore {
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
// SIGSEGV в StmtHolder.finalize на закрытой connection).
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
ORDER BY ${Schema.COL_ORDER_IDX} ASC
""".trimIndent()
)
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val maxOrderIdxStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
""".trimIndent()
)
private val dropFromIdxStmt: SQLitePreparedStatement = connection.prepare(
"""
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
""".trimIndent()
)
private val insertSummaryStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, NULL, ?, ?, ?)
""".trimIndent()
)
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
val newIdx = maxOrderIdx(conversationId) + 1
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, Ids.new("wm"))
insertStmt.bindText(2, conversationId)
insertStmt.bindLong(3, newIdx)
val srcId = entry.sourceMessageId
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
insertStmt.bindText(5, entryKind(entry))
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
insertStmt.bindLong(7, now.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
val out = mutableListOf<WorkingMemoryRow>()
listStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(
WorkingMemoryRow(
id = rs.getText(0)!!,
conversationId = rs.getText(1)!!,
orderIdx = rs.getLong(2)!!,
sourceMessageId = rs.getText(3),
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
)
)
}
}
out
}
}
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String?,
): Long = withContext(Dispatchers.Default) {
mutex.withLock {
var newMax = 0L
val nowMs = Clock.System.now().toEpochMilliseconds()
val summaryId = Ids.new("wm")
connection.exec("BEGIN")
try {
dropFromIdxStmt.reset()
dropFromIdxStmt.clearBindings()
dropFromIdxStmt.bindText(1, conversationId)
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
dropFromIdxStmt.executeUpdate()
if (!summaryText.isNullOrBlank()) {
val afterDelete = maxOrderIdx(conversationId)
val newIdx = afterDelete + 1
insertSummaryStmt.reset()
insertSummaryStmt.clearBindings()
insertSummaryStmt.bindText(1, summaryId)
insertSummaryStmt.bindText(2, conversationId)
insertSummaryStmt.bindLong(3, newIdx)
insertSummaryStmt.bindText(4, "summary")
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
insertSummaryStmt.bindLong(6, nowMs)
insertSummaryStmt.executeUpdate()
newMax = newIdx
} else {
newMax = maxOrderIdx(conversationId)
}
connection.exec("COMMIT")
} catch (t: Throwable) {
runCatching { connection.exec("ROLLBACK") }
throw t
}
newMax
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
maxOrderIdxStmt.close()
dropFromIdxStmt.close()
insertSummaryStmt.close()
}
private fun maxOrderIdx(conversationId: String): Long {
maxOrderIdxStmt.reset()
maxOrderIdxStmt.clearBindings()
maxOrderIdxStmt.bindText(1, conversationId)
maxOrderIdxStmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0) ?: 0L
}
return 0L
}
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
is WorkingMemoryEntry.User -> "user"
is WorkingMemoryEntry.Assistant -> "assistant"
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
is WorkingMemoryEntry.Summary -> "summary"
}
}
@@ -0,0 +1,98 @@
package pw.binom.agentik.context.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:context-api`.
*
* Минимум — только то, что относится к `working_memory` (реализация
* [KsqliteContextStore]). Остальные таблицы агента (`conversation`,
* `message`, `reflection`) живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
const val TABLE_WORKING_MEMORY = "working_memory"
// ───── Колонки ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_ORDER_IDX = "order_idx"
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ─────
const val IDX_WM_UNIQUE = "idx_wm_unique"
const val IDX_WM_CONV = "idx_wm_conv"
private val v1Ddl = """
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_ORDER_IDX INTEGER NOT NULL,
$COL_SOURCE_MESSAGE_ID TEXT,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
""".trimIndent()
private val v1IndexesDdl = """
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
""".trimIndent()
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
*/
fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
}
@@ -0,0 +1,107 @@
package pw.binom.agentik.context.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.journal.Content
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Тесты для [KsqliteContextStore] — точная копия
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
* та же).
*
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
* автономный.
*/
class KsqliteContextStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteContextStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteContextStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User =
WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content)))
private fun asstMsg(content: String): WorkingMemoryEntry.Assistant =
WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content)))
@Test
fun testAppendAndListReturnsInOrder() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("first"), t)
store.append("c1", asstMsg("reply"), t)
val list = store.list("c1")
assertEquals(2, list.size)
assertEquals(1L, list[0].orderIdx)
assertEquals(2L, list[1].orderIdx)
}
@Test
fun testListIsolatesConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("c1-msg"), t)
store.append("c2", userMsg("c2-msg"), t)
assertEquals(1, store.list("c1").size)
assertEquals(1, store.list("c2").size)
}
@Test
fun testClearRemovesAllForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
store.append("c1", userMsg("b"), t)
store.clear("c1")
assertEquals(emptyList(), store.list("c1"))
}
@Test
fun testCompactDeletesAndInsertsSummary() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
store.append("c1", userMsg("b"), t)
store.append("c1", userMsg("c"), t)
// dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a).
// Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2.
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary")
assertEquals(2L, newMax)
val remaining = store.list("c1")
assertEquals(2, remaining.size)
assertEquals(1L, remaining[0].orderIdx)
assertEquals(2L, remaining[1].orderIdx)
assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary)
}
@Test
fun testCompactWithoutSummaryKeepsTailBelow() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
// dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1.
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
assertEquals(1L, newMax)
val remaining = store.list("c1")
assertEquals(1, remaining.size)
assertEquals(1L, remaining[0].orderIdx)
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ Main.kt
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx, - `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
summarization-вставка отложена (нужен дизайн-проработка). summarization-вставка отложена (нужен дизайн-проработка).
**`MessageStore`** — append-only аудит. На каждый ход дописываются **`JournalStore`** — append-only аудит. На каждый ход дописываются
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких `UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
update/delete кроме каскада из `ConversationStore.delete`. update/delete кроме каскада из `ConversationStore.delete`.
+3 -3
View File
@@ -175,7 +175,7 @@ fun main() {
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов). **Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
### `MessageStore` ### `JournalStore`
```kotlin ```kotlin
suspend fun append(record: MessageRecord) suspend fun append(record: MessageRecord)
@@ -183,7 +183,7 @@ suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int
suspend fun listAll(conversationId: String): List<MessageRecord> suspend fun listAll(conversationId: String): List<MessageRecord>
``` ```
### `WorkingMemoryStore` ### `ContextStore`
```kotlin ```kotlin
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
@@ -194,7 +194,7 @@ suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста). `compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
### `ConversationStore` ### `MutableConversationStore`
```kotlin ```kotlin
suspend fun upsert(record: ConversationRecord) suspend fun upsert(record: ConversationRecord)
+137
View File
@@ -0,0 +1,137 @@
# `:event-store` — bounded-tail event log (KMP)
## Что это
Двухуровневое хранилище событий агента. Этот модуль — **короткий
bounded tail** для live-SSE и недавнего replay. Полный audit log
живёт в `:message-store-api` (никогда не эвиктится, source of truth).
Три принципа:
1. **Tail управляет TTL сам.** Никаких `prune`/`cleanup` методов наружу —
implementation решает, когда выкинуть старый event. Caller'ы не
могут забыть cleanup.
2. **Catchup + live в одном Flow.** `events(after)` сначала отдаёт
буферизованный диапазон, потом переключается на live tail — клиент
не должен знать, где у него "разрыв".
3. **Read-only контракт для consumer'ов.** Запись через
[MutableEventStore], чтение через [EventStore]. Compile-time
гарантия что observer не сможет писать в store.
## Где используется
- `:standalone` ChatAgent — append через `MutableOutboxStore` (заменяет
текущий `agentEvents: MutableSharedFlow` + `persistAgentEvent`).
- `:server` Routes.kt — `/events/all` SSE endpoint читает через
`EventStore.events(after)`.
- Будущий `:android-agent` core — same интерфейс для локального
bounded tail без dedicated server connection.
## Архитектура
```
┌─ :event-store (этот модуль) ────────────────────────┐
│ Bounded tail с auto-TTL: │
│ • append(event) ← producer │
│ • events(after): Flow ← consumer │
│ • earliestEventDate() для gap detection │
│ TTL/cap eviction — внутри impl │
└───────────────────────────────────────────────────┘
▲ gap detected
│
┌─ :message-store-api (полный audit log) ───────────┐
│ MessageStore: query(after, before, limit) │
│ Никогда не эвиктится. Source of truth. │
└───────────────────────────────────────────────────┘
```
**Reconnect pattern** (caller делает):
```kotlin
val earliest = eventStore.earliestEventDate()
if (client.lastSeen < earliest) {
// gap: догоняем через :message-store-api
val gap = messageStore.query(after = client.lastSeen, before = earliest)
applyAll(gap)
client.lastSeen = gap.last().createdAt
}
eventStore.events(after = client.lastSeen).collect { apply(it) }
```
## API
### `OutboxStore` (read-only, для consumer'ов)
```kotlin
interface EventStore : AutoCloseable {
fun events(after: Instant?): Flow<CommonEvent>
fun conversationEvents(
after: Instant?,
conversationId: String? = null, // null = все диалоги
): Flow<CommonEvent.Conversation>
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent>
suspend fun earliestEventDate(): Instant // non-null: now() для пустого буфера
override fun close()
}
```
**Семантика фильтров**:
- `conversationEvents(null)` — все диалоги.
- `conversationEvents("c-123")` — один конкретный диалог.
- `agentEvents(...)` — только lifecycle (Created/Deleted/Renamed).
Все три возвращают **типизированные** subtype'ы [CommonEvent], так что
caller'у не нужно `.filterIsInstance` на клиентской стороне.
### `MutableEventStore : EventStore` (для producer'ов)
```kotlin
interface MutableEventStore : EventStore {
suspend fun append(event: CommonEvent)
}
```
**Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
retry даст дубликат. Для exactly-once — dedup через
`:message-store-api` (там есть монотонный `id`).
**Silently evicted**: implementation может выкинуть event сразу после
append по TTL/cap. Producer не должен полагаться на то, что event
дойдёт до клиента, если он вне retention window.
## Когда использовать какой интерфейс
| Caller | Method |
|---|---|
| Server `/events/all` SSE (mixed) | `events(after)` |
| Server `/conversations/{id}/events` SSE | `conversationEvents(after, conversationId)` |
| Server `/agent/events` SSE (lifecycle only) | `agentEvents(after)` |
| Admin dashboard (lifecycle) | `agentEvents(after)` |
| Parent orchestrator (mixed) | `events(after)` |
| Тесты | `events(after)` + projection через фильтр |
## Как добавить новый implementation
1. Создать класс с конструктором и lifecycle (`close()` обязан
освободить ресурсы).
2. Реализовать минимум: append (с TTL eviction), events (Flow с
catchup + live), earliestEventDate (non-null Instant, now() если
буфер пуст).
3. Для persistent impl: SQL/ksqlite таблица с индексом по date,
вставка = `INSERT OR IGNORE` для дедупликации на уровне БД
(если в схеме будет id).
## Текущее состояние
- ✅ Interface дизайн (`OutboxStore` + `MutableOutboxStore`)
- ✅ KMP build (jvm + linuxX64 + mingwX64)
- ⏳ Нет implementations (next: `InMemoryEventStore` для тестов)
- ⏳ Не интегрирован в `:standalone`/`:server`
## Зависимости
- `:proto` (api) — тип `CommonEvent` (3 AgentEvent + 9 Conversation.Event вариантов).
- `kotlinx-coroutines-core` (api) — `Flow`.
Никаких `kotlinx-serialization`, `kotlin-logging`, platform-specific
зависимостей — этот модуль намеренно minimal.
+32
View File
@@ -0,0 +1,32 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-интерфейс bounded-tail event log'а. Implementation-specific TTL/cap
// eviction — caller's responsibility НЕ вызывать cleanup() (метод не существует).
//
// Тип [AllEvent] из :proto — typed envelope (3 AgentEvent + 9 Conversation.Event
// вариантов). :event-store отвечает за bounded-tail с auto-TTL, но не за
// сериализацию envelope'а — это делает :proto (уже @Serializable).
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":proto"))
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
+10 -3
View File
@@ -10,7 +10,7 @@ litert = "8"
sqldelight = "2.3.2" sqldelight = "2.3.2"
shadow = "8.3.5" shadow = "8.3.5"
jvector = "3.0.6" jvector = "3.0.6"
text-embedding-kmp = "3.0.0-SNAPSHOT" text-embedding-kmp = "5"
kotlin-logging = "3.0.5" kotlin-logging = "3.0.5"
logback = "1.5.18" logback = "1.5.18"
mosaic = "0.18.0" mosaic = "0.18.0"
@@ -97,8 +97,15 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" } jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. --- # --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета). # `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" } # (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
# Версия 5 — первый релиз с реальными нативными klib-вариантами в caffeine
# (v4 имел только jvm+android, что ломало native-resolve в :memory-md-vector).
# Используется из :memory-md-vector и :memory-vector напрямую через
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
# нужный variant под target).
# `siglip` — JVM+Android only (onnx-runtime), подключается в jvmMain.
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "text-embedding-kmp" }
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" } text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). --- # --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
+29
View File
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,17 +1,12 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Часть контента сообщения на уровне хранилища. * Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
* * `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг * в `Mapping.kt` storage impl'ов.
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
* идентичны, но даёт возможность заменить transport-протокол без миграции
* таблиц.
*
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
*/ */
@Serializable @Serializable
sealed interface Content { sealed interface Content {
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlin.time.Instant import kotlin.time.Instant
@@ -0,0 +1,45 @@
package pw.binom.agentik.journal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* Read-only view of the `conversation` table (CRUD-операции находятся
* в [MutableConversationStore] и используются только внутри ChatAgent).
*
* Клиенты видят [ConversationStore] через [pw.binom.agentik.proto.Agent.conversationStore]
* (по аналогии с `journal` / `outbox`) и строят свой локальный кэш:
* - **seed** через [list] (snapshot страницы) или [listFlow] (cold-flow paging);
* - **live-refresh** через `outbox.agentEvents()` — Created / Deleted /
* Renamed / Touched.
*
* Запись в хранилище **не** делается клиентом — только команды
* `agent.createConversation / deleteConversation / renameConversation`.
*/
interface ConversationStore : AutoCloseable {
/** Диалог по id, или `null`. */
suspend fun get(id: String): ConversationRecord?
/** Список диалогов, отсортированный по `updatedAt` DESC. */
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
/**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для
* HTTP-импл — это лишние round-trip'ы; реализация может переопределить.
*/
fun listFlow(offset: Int = 0, pageSize: Int = PAGE_SIZE): Flow<ConversationRecord> = flow {
var skip = offset
while (true) {
val page = list(skip, pageSize)
if (page.isEmpty()) break
page.forEach { emit(it) }
skip += page.size
}
}
companion object {
const val PAGE_SIZE: Int = 100
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -11,5 +11,4 @@ import kotlin.uuid.Uuid
*/ */
object Ids { object Ids {
fun new(prefix: String): String = "$prefix-${Uuid.random()}" fun new(prefix: String): String = "$prefix-${Uuid.random()}"
fun reflection(): String = new("refl")
} }
@@ -0,0 +1,41 @@
package pw.binom.agentik.journal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Instant
/**
* Append-only audit log сообщений — read-only представление.
*
* Producer-операция [MutableJournalStore.append] находится на
* [MutableJournalStore] — этот интерфейс только для чтения, чтобы
* consumer'ы физически не могли писать в audit log.
*
* Никаких обновлений, никакого удаления (кроме каскадного вместе
* с ConversationStore.delete).
*/
interface JournalStore : AutoCloseable {
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для
* in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть
* на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip.
*/
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
var offset = 0
while (true) {
val page = list(conversationId, after, offset, pageSize)
if (page.isEmpty()) return@flow
for (rec in page) emit(rec)
if (page.size < pageSize) return@flow
offset += page.size
}
}
companion object {
const val PAGE_SIZE = 100
}
}
@@ -0,0 +1,12 @@
package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class MessageContext(
val origin: MessageOrigin,
val sourceId: String? = null,
val description: String? = null,
val metadata: JsonElement? = null,
)
@@ -0,0 +1,20 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Контекст инициации хода (кто/что и почему). Дубликат типа из `:proto` —
* живёт здесь чтобы не тащить `:proto` в слой хранения данных.
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@@ -0,0 +1,79 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Запись в таблице `message` (append-only audit).
*/
@Serializable
sealed interface MessageRecord {
val id: String
val conversationId: String
val createdAt: Instant
@Serializable
sealed interface Body : MessageRecord {
val content: List<Content>
}
@Serializable
@SerialName("user")
data class UserMessage(
override val id: String,
override val conversationId: String,
override val content: List<Content>,
override val createdAt: Instant,
val context: MessageContext? = null,
) : Body
@Serializable
@SerialName("assistant")
data class AssistantMessage(
override val id: String,
override val conversationId: String,
override val content: List<Content>,
override val createdAt: Instant,
val tokens: TurnTokens? = null,
) : Body
@Serializable
@SerialName("tool_call")
data class ToolCall(
override val id: String,
override val conversationId: String,
val toolName: String,
val toolTitle: String?,
val toolArgsJson: String,
override val createdAt: Instant,
) : MessageRecord
@Serializable
@SerialName("tool_result")
data class ToolResult(
override val id: String,
override val conversationId: String,
val toolCallId: String,
/**
* Имя тула, денормализованное из соответствующего `MessageRecord.ToolCall.toolName`.
* Денормализация экономна (одна строка в SQLite) и снимает с UI
* необходимость сопоставления `toolCallId → toolName`. `null` —
* безопасный backfill для записей до миграции или для сиротливых
* результатов без предшествующего `ToolCall`.
*/
val toolName: String? = null,
val result: String?,
override val createdAt: Instant,
) : MessageRecord
@Serializable
@SerialName("error")
data class Error(
override val id: String,
override val conversationId: String,
val message: String,
val code: String?,
override val createdAt: Instant,
) : MessageRecord
}
@@ -1,24 +1,18 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* CRUD по таблице `conversation`. * CRUD по таблице `conversation`.
*/ */
interface ConversationStore : AutoCloseable { interface MutableConversationStore : ConversationStore {
/** Создать или обновить snapshot диалога. */ /** Создать или обновить snapshot диалога. */
suspend fun upsert(record: ConversationRecord) suspend fun upsert(record: ConversationRecord)
/** Диалог по id, или `null`. */
suspend fun get(id: String): ConversationRecord?
/** Удалить диалог (вместе с его сообщениями и working memory). */ /** Удалить диалог (вместе с его сообщениями и working memory). */
suspend fun delete(id: String): Boolean suspend fun delete(id: String): Boolean
/** Список диалогов, отсортированный по `updatedAt` DESC. */
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */ /** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
suspend fun rename(id: String, title: String?): Instant? suspend fun rename(id: String, title: String?): Instant?
@@ -0,0 +1,17 @@
package pw.binom.agentik.journal
/**
* Mutable вариант [JournalStore] — добавляет producer-операцию [append].
*
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
* ConversationLoop, ToolDispatcher, sub-agents, A2A-bridge).
* Consumer'ы (DebugRoutes, admin dashboards, parent agents) должны
* принимать **read-only** [JournalStore] — тогда невозможно случайно
* записать в audit log из observer'а.
*
* **Append семантика**: см. KDoc [MessageStore.append][JournalStore] —
* на этом интерфейсе (не дублируем).
*/
interface MutableJournalStore : JournalStore {
suspend fun append(record: MessageRecord)
}
@@ -0,0 +1,47 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
private val bodyJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Serializable
data class MessageBodyPayload(
val content: List<Content>,
@SerialName("context")
val context: MessageContext? = null,
val tokens: TurnTokens? = null,
)
fun encodeBodyPayload(
content: List<Content>,
context: MessageContext? = null,
tokens: TurnTokens? = null,
): String = bodyJson.encodeToString(
MessageBodyPayload.serializer(),
MessageBodyPayload(content = content, context = context, tokens = tokens),
)
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
data class BodyDecoded(
val content: List<Content>,
val context: MessageContext?,
val tokens: TurnTokens? = null,
)
private fun readPayload(json: String): BodyDecoded {
return try {
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
BodyDecoded(p.content, p.context, p.tokens)
} catch (e: kotlinx.serialization.SerializationException) {
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
BodyDecoded(arr, null, null)
}
}
@@ -0,0 +1,18 @@
package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
/**
* Token usage одного assistant turn'а.
*/
@Serializable
data class TurnTokens(
val input: Int,
val output: Int,
) {
val total: Int get() = input + output
init {
require(input >= 0) { "input tokens must be non-negative, got $input" }
require(output >= 0) { "output tokens must be non-negative, got $output" }
}
}
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [MutableJournalStore] и [MutableConversationStore] на
// `MutableList`/`MutableMap` + `Mutex` — для тестов, dev-режима,
// embedded-сценариев (Android core, CLI, in-process кэш в клиенте) и как
// образец для своей реализации.
//
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,68 @@
package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import kotlin.time.Instant
/**
* Простая in-memory [MutableJournalStore] для тестов, dev-режима и
* клиентских in-process кэшей.
*
* **Thread-safety**: `Mutex` поверх `MutableList<MessageRecord>`. Для
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
*
* **Контракт `list`**: возвращает подмножество с
* `conversationId == conversationId && createdAt > after`, отсортированное
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка.
*
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
* на сервере). [close] — no-op.
*
* Типичный кэш-паттерн в клиенте:
* ```
* val local = InMemoryJournalStore()
* val remote = HttpJournalStore(httpClient, baseUrl)
* // backfill + кэширование:
* remote.listFlow(convId, Instant.DISTANT_PAST).collect { local.append(it) }
* // после этого `local.list(convId, after, offset, limit)` отдаёт из кэша.
* ```
*/
class InMemoryJournalStore : MutableJournalStore {
private val mutex = Mutex()
private val records: MutableList<MessageRecord> = mutableListOf()
override suspend fun append(record: MessageRecord): Unit = mutex.withLock {
records.add(record)
}
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = mutex.withLock {
records.asSequence()
.filter { it.conversationId == conversationId && it.createdAt > after }
.sortedBy { it.createdAt }
.drop(offset)
.take(limit)
.toList()
}
/** Сбросить кэш (например, когда диалог удалён). */
suspend fun clear(): Unit = mutex.withLock {
records.clear()
}
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
suspend fun size(): Int = mutex.withLock { records.size }
override fun close() {
// no-op: lifecycle HttpClient'а — снаружи.
}
}
@@ -1,22 +1,33 @@
package pw.binom.agentik.storage.inmemory package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore
import kotlin.time.Clock import kotlin.time.Clock
import pw.binom.agentik.storage.ConversationRecord
import pw.binom.agentik.storage.ConversationStore
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Thread-safe Map-импл [ConversationStore]. * Thread-safe Map-импл [MutableConversationStore] для клиентских
* in-process кэшей (и тестов/dev-режима).
* *
* Использует `Mutex` для атомарности read-modify-write операций * Использует `Mutex` для атомарности read-modify-write операций
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления * (rename, touch) — иначе два параллельных `rename` могут потерять обновления
* (lost-update race), что в SQLite невозможно из-за driver-level locking. * (lost-update race), что в SQLite невозможно из-за driver-level locking.
*
* **Сортировка**: `list()` сортирует по `updatedAt DESC`.
*
* **Типичный кэш-паттерн в клиенте** (см. `client/README.md`):
* ```
* val local = InMemoryMutableConversationStore()
* // seed: remote.listFlow → local.upsert
* // live-refresh: outbox.agentEvents → local.upsert/delete/rename/touch
* // UI: local.list(0, PAGE_SIZE)
* ```
*/ */
class InMemoryConversationStore( class InMemoryMutableConversationStore(
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) : ConversationStore { ) : MutableConversationStore {
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf() private val byId: MutableMap<String, ConversationRecord> = mutableMapOf()
private val mutex = Mutex() private val mutex = Mutex()
@@ -0,0 +1,78 @@
package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class InMemoryJournalStoreTest {
private fun userMsg(id: String, convId: String, text: String, at: Instant) =
MessageRecord.UserMessage(
id = id,
conversationId = convId,
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
createdAt = at,
)
@Test
fun `append then list returns records sorted by createdAt ASC`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "first", t0))
store.append(userMsg("m2", "c1", "second", t0 + 1.seconds))
store.append(userMsg("m3", "c1", "third", t0 + 2.seconds))
val all = store.list("c1", Instant.DISTANT_PAST, 0, 100)
assertEquals(3, all.size)
assertEquals(listOf("m1", "m2", "m3"), all.map { it.id })
}
@Test
fun `list filters by conversationId`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c2", "b", t0 + 1.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 2.seconds))
assertEquals(2, store.list("c1", Instant.DISTANT_PAST, 0, 100).size)
assertEquals(1, store.list("c2", Instant.DISTANT_PAST, 0, 100).size)
}
@Test
fun `list filters by after cursor`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
val afterT0 = store.list("c1", t0, 0, 100)
assertEquals(listOf("m2", "m3"), afterT0.map { it.id })
}
@Test
fun `list applies offset and limit`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
repeat(10) { i -> store.append(userMsg("m$i", "c1", "x", t0 + i.seconds)) }
val page = store.list("c1", Instant.DISTANT_PAST, offset = 3, limit = 4)
assertEquals(listOf("m3", "m4", "m5", "m6"), page.map { it.id })
}
@Test
fun `clear empties the cache`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "x", t0))
assertEquals(1, store.size())
store.clear()
assertEquals(0, store.size())
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
}
}
@@ -1,6 +1,6 @@
package pw.binom.agentik.storage.inmemory package pw.binom.agentik.journal.inmemory
import pw.binom.agentik.storage.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@@ -9,11 +9,11 @@ import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
class InMemoryConversationStoreTest { class InMemoryMutableConversationStoreTest {
@Test @Test
fun `upsert and get roundtrip preserves all fields`() = runTest { fun `upsert and get roundtrip preserves all fields`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val rec = ConversationRecord( val rec = ConversationRecord(
id = "c1", id = "c1",
title = "test", title = "test",
@@ -28,13 +28,13 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `get returns null for missing id`() = runTest { fun `get returns null for missing id`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
assertNull(store.get("nope")) assertNull(store.get("nope"))
} }
@Test @Test
fun `delete removes the record and returns true`() = runTest { fun `delete removes the record and returns true`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
store.upsert( store.upsert(
ConversationRecord( ConversationRecord(
"c1", null, false, "c1", null, false,
@@ -50,7 +50,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest { fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0)) store.upsert(ConversationRecord("c1", null, false, t0, t0))
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S")))) store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
@@ -69,7 +69,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `rename updates title and updatedAt returns new updatedAt`() = runTest { fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0)) store.upsert(ConversationRecord("c1", null, false, t0, t0))
@@ -84,7 +84,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `rename with null title clears it`() = runTest { fun `rename with null title clears it`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", "old", false, t0, t0)) store.upsert(ConversationRecord("c1", "old", false, t0, t0))
store.rename("c1", null) store.rename("c1", null)
@@ -93,13 +93,13 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `rename returns null for missing conversation`() = runTest { fun `rename returns null for missing conversation`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
assertNull(store.rename("nope", "x")) assertNull(store.rename("nope", "x"))
} }
@Test @Test
fun `touch bumps updatedAt without changing other fields`() = runTest { fun `touch bumps updatedAt without changing other fields`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z") val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0)) store.upsert(ConversationRecord("c1", "title", false, t0, t0))
@@ -112,7 +112,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `close is idempotent and does nothing`() { fun `close is idempotent and does nothing`() {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
store.close() store.close()
store.close() // должно быть no-op store.close() // должно быть no-op
} }
+35
View File
@@ -0,0 +1,35 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// KMP-реализация :journal-api (JournalStore / MutableJournalStore) поверх ksqlite.
// Минимальная — только таблица `message` для append-only audit log'а.
// ConversationStore / ReflectionStore / WorkingMemoryStore живут в своих
// собственных ksqlite-модулях.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json)
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,117 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
*
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore]
* из `:storage-ksqlite`, но:
* - лежит в собственном модуле `:journal-ksqlite`;
* - реализует переименованный [MutableJournalStore] (раньше был
* `MutableMessageStore`, теперь главный класс — `JournalStore` /
* `MutableJournalStore`); сам тип записи [MessageRecord] не
* переименовывался.
*
* Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
*
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
* отдельно.
*/
class KsqliteJournalStore internal constructor(
private val connection: SQLiteConnection,
) : MutableJournalStore {
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_MESSAGE}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
ORDER BY ${Schema.COL_CREATED_AT} ASC, ${Schema.COL_ID} ASC
LIMIT ? OFFSET ?
""".trimIndent()
)
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record)
mutex.withLock {
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, record.id)
insertStmt.bindText(2, record.conversationId)
insertStmt.bindText(3, kind)
insertStmt.bindText(4, payload)
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
listStmt.bindLong(2, after.toEpochMilliseconds())
listStmt.bindLong(3, limit.toLong())
listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json))
}
out
}
}
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
}
}
@@ -0,0 +1,89 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.decodeBodyPayload
import pw.binom.agentik.journal.encodeBodyPayload
import pw.binom.db.ksqlite.SQLiteResultSet
import kotlin.time.Instant
/**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
*
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
*/
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
content = record.content,
context = record.context,
)
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
content = record.content,
tokens = record.tokens,
)
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(),
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
)
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
ResultPayload.serializer(),
ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
)
is MessageRecord.Error -> "error" to Json.encodeToString(
ErrorPayload.serializer(),
ErrorPayload(message = record.message, code = record.code),
)
}
internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
val id = getText(0)!!
val convId = getText(1)!!
val kind = getText(2)!!
val payload = getText(3)!!
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
return when (kind) {
"user" -> {
val d = decodeBodyPayload(payload)
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
}
"assistant" -> {
val d = decodeBodyPayload(payload)
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
}
"tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload)
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
}
"tool_result" -> {
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
}
"error" -> {
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
}
else -> error("Unknown message kind in audit log: $kind")
}
}
@kotlinx.serialization.Serializable
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
/**
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
* без поля десериализуются как `null`.
*/
@kotlinx.serialization.Serializable
internal data class ResultPayload(
val toolCallId: String,
val toolName: String? = null,
val result: String?,
)
@kotlinx.serialization.Serializable
internal data class ErrorPayload(val message: String, val code: String?)
@@ -0,0 +1,91 @@
package pw.binom.agentik.journal.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
*
* Минимум — только то, что относится к `message` (append-only audit log).
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
* живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
const val TABLE_MESSAGE = "message"
// ───── Колонки ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ─────
const val IDX_MSG_CONV = "idx_msg_conv"
private val v1Ddl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
""".trimIndent()
private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
""".trimIndent()
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
*/
fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
}
@@ -0,0 +1,106 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.TurnTokens
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* Тесты для [KsqliteJournalStore] — точная копия
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory
* SQLiteConnection + Schema.migrate).
*
* Тест `testClearRemovesByConversation` из оригинала использовал
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
* автономен и не знает про ConversationStore.
*/
class KsqliteJournalStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteJournalStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteJournalStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
@Test
fun testAppendUserAndRetrieve() = runTest {
store.append(MessageRecord.UserMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hello")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
context = null,
))
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0]
assertEquals("m1", msg.id)
assertEquals(MessageRecord.UserMessage::class, msg::class)
}
@Test
fun testAppendAssistantWithTokens() = runTest {
store.append(MessageRecord.AssistantMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hi")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
tokens = TurnTokens(input = 50, output = 30),
))
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0] as MessageRecord.AssistantMessage
assertEquals(TurnTokens(input = 50, output = 30), msg.tokens)
}
@Test
fun testListAfterFiltersByTimestamp() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
val after = store.list("conv1", after = t1, offset = 0, limit = 10)
assertEquals(2, after.size)
assertEquals(listOf("m2", "m3"), after.map { it.id })
}
@Test
fun testListFlowReturnsAllInOrder() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..3) store.append(
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
)
assertEquals(listOf("m1", "m2", "m3"), store.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
}
@Test
fun testClearRemovesByConversation() = runTest {
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), Instant.parse("2026-09-15T10:00:00Z"), null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), Instant.parse("2026-09-15T10:00:00Z"), null))
store.clear("conv1")
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
api(project(":memory-api")) api(project(":memory-api"))
api(project(":storage-core")) api(project(":journal-api"))
api(project(":reflection-api"))
api(project(":context-api"))
api(project(":skills")) api(project(":skills"))
api(libs.litert.api) api(libs.litert.api)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.NewMemoryNote import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.storage.Ids import pw.binom.agentik.journal.Ids
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -5,8 +5,8 @@ import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import pw.binom.agentik.storage.Ids import pw.binom.agentik.journal.Ids
import pw.binom.agentik.storage.Reflection import pw.binom.agentik.reflection.Reflection
import kotlin.time.Clock import kotlin.time.Clock
/** /**
@@ -57,7 +57,7 @@ class LlmReflector(
val parsed = ReflectionParser.parse(raw) val parsed = ReflectionParser.parse(raw)
?: return@withContext null ?: return@withContext null
Reflection( Reflection(
id = Ids.reflection(), id = pw.binom.agentik.reflection.Ids.new(),
conversationId = null, // будет проставлен caller'ом ChatConversation conversationId = null, // будет проставлен caller'ом ChatConversation
createdAt = clock.now(), createdAt = clock.now(),
turnsAnalyzed = turns.size, turnsAnalyzed = turns.size,
+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() }
+32
View File
@@ -0,0 +1,32 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
// typealias-обёртки указывают на :journal-api — без него
// компиляция падает на Unresolved reference 'journal'.
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
// Нужен для @Serializable на AgentEvent/CommonEvent/Event — все
// три типа теперь живут в :outbox-api (см. миграцию из :proto).
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :proto больше не нужен — AgentEvent/CommonEvent/Event перенесены
// сюда, и они self-contained (Event ссылается только на kotlinx-serialization).
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,17 +1,22 @@
package pw.binom.agentik.proto package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Live-события уровня [Agent]: изменения в множестве диалогов * Live-события уровня агента: изменения в множестве диалогов
* (создание, удаление, переименование). События, происходящие **внутри** * (создание, удаление, переименование). События, происходящие **внутри**
* конкретного диалога, приходят через [Conversation.events], а не сюда. * конкретного диалога, приходят через `Conversation.events` (live-stream
* per-turn Event'ов), а не сюда.
* *
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки * Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
* идентична [Conversation.events]: поток **не реплеит** прошлое, для бэкфилла * идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла
* используются `getConversations`/`getConversation`. * используются `Agent.getConversations` / `getConversation`.
*
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.AgentEvent`;
* typealias удалён 2026-09-21 (стирал nested-типы в `is`/`when`) — потребители
* импортируют напрямую из `pw.binom.agentik.outbox.AgentEvent`.
*/ */
@Serializable @Serializable
sealed interface AgentEvent { sealed interface AgentEvent {
@@ -20,15 +25,15 @@ sealed interface AgentEvent {
/** /**
* Создан новый диалог. Передаётся его id — handle можно получить через * Создан новый диалог. Передаётся его id — handle можно получить через
* [Agent.getConversation]. Подписчик после [Created] может сразу открыть * `Agent.getConversation`. Подписчик после [Created] может сразу открыть
* live-подписку на этот диалог через [Conversation.events]. * live-подписку на этот диалог через `Conversation.events`.
*/ */
@Serializable @Serializable
@SerialName("created") @SerialName("created")
data class Created(override val date: Instant, val conversationId: String) : AgentEvent data class Created(override val date: Instant, val conversationId: String) : AgentEvent
/** /**
* Диалог удалён. Переданный [Conversation]-handle реализация обязана * Диалог удалён. Переданный `Conversation`-handle реализация обязана
* закрыть (`close()`) до эмиссии этого события — после [Deleted] * закрыть (`close()`) до эмиссии этого события — после [Deleted]
* пользоваться handle нельзя. * пользоваться handle нельзя.
*/ */
@@ -40,4 +45,13 @@ sealed interface AgentEvent {
@Serializable @Serializable
@SerialName("renamed") @SerialName("renamed")
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent 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
}
@@ -1,11 +1,11 @@
package pw.binom.agentik.proto package pw.binom.agentik.outbox
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Элемент live-потока [Conversation.events]. * Элемент live-потока `Conversation.events(after)`.
* *
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом * Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
* для трекинга «где остановился» при обрыве/переподключении и для разрешения * для трекинга «где остановился» при обрыве/переподключении и для разрешения
@@ -14,6 +14,13 @@ import kotlin.time.Instant
* Базовая структура хода: * Базовая структура хода:
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`. * `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения. * `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
*
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
* backward-compat, но он стирал nested-типы (`Event.End`, `Event.ToolCall`,
* `Event.ToolResult`), что ломало `is Event.End` на стороне клиента.
* Typealias удалён 2026-09-21 — потребители импортируют напрямую из
* `pw.binom.agentik.outbox.Event`.
*/ */
@Serializable @Serializable
sealed interface Event { sealed interface Event {
@@ -36,12 +43,12 @@ sealed interface Event {
@SerialName("start_response") @SerialName("start_response")
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
/** Ход завершён нормально. Соответствующий [Message.AssistantMessage] появится в `getMessages`. */ /** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
@Serializable @Serializable
@SerialName("end") @SerialName("end")
data class End(override val date: Instant) : Event data class End(override val date: Instant) : Event
/** Ход прерван через [Conversation.interrupt]. Частичный ответ НЕ сохраняется в истории. */ /** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
@Serializable @Serializable
@SerialName("interrupted") @SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event data class Interrupted(override val date: Instant) : Event
@@ -56,7 +63,7 @@ sealed interface Event {
/** /**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет. * Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
* [id] совпадает с id соответствующего [Message.ToolCall] в истории * [id] совпадает с id соответствующего `Message.ToolCall` в истории
* после завершения хода. * после завершения хода.
*/ */
@Serializable @Serializable
@@ -71,12 +78,26 @@ sealed interface Event {
/** /**
* Результат вызова тула. Приходит целиком после завершения исполнения. * Результат вызова тула. Приходит целиком после завершения исполнения.
* [id] совпадает с [ToolCall.id], к которому относится результат, и *
* с id [Message.ToolResult] в истории. * [toolCallId] = id [ToolCall], к которому относится результат, и
* `MessageRecord.ToolResult.toolCallId` в истории. Один Call → один Result,
* пара `(date, toolCallId)` уникальна — отдельный `id` в live-событии
* не нужен (PK живёт в персистентном журнале).
*
* [toolName] денормализован из соответствующего [ToolCall.toolName] —
* UI рендерит имя тула без локальной `Map<toolCallId, name>` и без риска
* «Result пришёл до Call». `null` допустим для backfill'а старых
* записей, у которых поле отсутствует, или теоретического случая
* Result без предшествующего Call (orphan).
*/ */
@Serializable @Serializable
@SerialName("tool_result") @SerialName("tool_result")
data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event data class ToolResult(
override val date: Instant,
val toolCallId: String,
val toolName: String? = null,
val result: String?,
) : Event
/** /**
* Ошибка хода. После неё поток завершается; дальнейшие события могут * Ошибка хода. После неё поток завершается; дальнейшие события могут
@@ -0,0 +1,50 @@
package pw.binom.agentik.outbox
/**
* Mutable вариант [OutboxStore] — добавляет producer-операцию [append].
*
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
* sub-agents, A2A-bridge). Consumer'ы (server SSE endpoints, admin
* dashboards, parent agents) должны принимать **read-only** [OutboxStore]
* — тогда невозможно случайно писать в store из observer'а.
*
* Типичное использование:
* ```
* // Producer
* class ChatAgent(private val events: MutableEventStore) {
* suspend fun doSomething() {
* events.append(CommonEvent.Agent(date = now, event = AgentEvent.Created(...)))
* }
* }
*
* // Consumer
* class EventStreamEndpoint(private val events: EventStore) {
* fun stream() = events.events(after = null)
* // Ошибка компиляции если раскомментировать:
* // events.append(...) // ← нельзя, MutableEventStore нет в типе
* }
* ```
*
* **Append НЕ идемпотентен**: [CommonEvent] не имеет уникального id,
* поэтому retry с тем же logical event (например, после network failure
* между producer и store) приведёт к дубликату в tail'е. Это OK для
* use case'a bounded-tail — клиент, делающий catchup через [events](after),
* получит свой диапазон ровно один раз при подключении, а последующие
* retry producer'а просто насытят tail повторами, не задевая уже
* обработанные. Для гарантированной exactly-once — dedup через
* [message-store] (там есть монотонный `id`).
*
* **Silently evicted**: implementation может выкинуть этот event сразу
* после append (TTL/cap) без уведомления producer'а. Producer **не
* должен** полагаться на то, что event дойдёт до клиента, если он
* вне retention window.
*/
interface MutableOutboxStore : OutboxStore {
/**
* Положить event в log.
*
* - **Не идемпотентно** — см. KDoc интерфейса.
* - **Suspend** для KMP I/O impl'ов (SQLite через JNI).
*/
suspend fun append(event: CommonEvent)
}
@@ -0,0 +1,144 @@
package pw.binom.agentik.outbox
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlin.time.Instant
/**
* Bounded-tail event log с автоматическим управлением TTL.
*
* **Архитектура двухуровневого хранилища событий**:
* 1. **Этот store** = короткий bounded tail (live SSE + недавний replay).
* События автоматически эвиктятся по TTL/cap (implementation-defined).
* 2. **Message store (`:message-store-api`)** = полный audit log, никогда не
* эвиктится. Source of truth для всего прошлого.
*
* **Паттерн reconnect** (caller'ы):
* ```
* val earliest = store.earliestEventDate()
* if (client.lastSeen < earliest) {
* // gap обнаружен — идём в message store за прошлым
* val gap = messageStore.query(after = client.lastSeen, before = earliest)
* applyAll(gap)
* }
* store.events(after = client.lastSeen).collect { apply(it) }
* ```
*
* **Нет delete/cleanup методов** — TTL/cap eviction полностью на стороне
* implementation. Это:
* - Убирает single source of truth дублирование (caller не может забыть cleanup).
* - Позволяет impl выбирать retention strategy (TTL, size cap, sliding window).
* - Сохраняет контракт clean: интерфейс только о put/get.
*
* **Read-only**: этот интерфейс предоставляет только read-операции.
* Для записи см. [MutableOutboxStore].
*
* **Подписки нереентрантные**: каждый вызов [events] создаёт **новую
* подписку** (cold Flow). Один [events] НЕ видит события, добавленные до
* его вызова, если [after] == null. Если нужен catchup — передавайте
* `after = lastSeenDate` явно.
*
* **Multi-consumer**: разные [events] подписки видят одно и то же live
* tail. Каждая подписка — независимая projection.
*/
interface OutboxStore : AutoCloseable {
/**
* Subscribe на events.
*
* **`after == null`** → только **live** (события с момента вызова
* `events()`). Каждое новое событие от любого producer'а немедленно
* появится в Flow. Буфер replay не отдаётся.
*
* **`after != null`** → сначала **catchup**: эмитт все буферизованные
* события с `date > after`, порядок `date ASC` (ties по `id ASC`).
* Затем **live** (как null-case).
*
* Cold Flow: каждый вызов — новая подписка. Вызов **после** append'а
* не увидит этот конкретный event (если `after == null`); для catchup
* передавайте явный `after`.
*
* ВАЖНО: `Flow` НЕ бросает ошибку при потере сети между producer и
* store — такие события просто не дойдут до этого Flow. Для гарантии
* полноты клиент обязан cross-check с [earliestEventDate] и fallback
* в message store при gap'е (см. KDoc интерфейса).
*/
fun events(after: Instant?): Flow<CommonEvent>
/**
* Subscribe на **только conversation events** (т.е. [CommonEvent.Conversation]).
*
* - [conversationId] == null → события **всех** диалогов.
* - [conversationId] != null → события **только этого** диалога.
*
* Семантика `after` идентична [events] (catchup + live).
* Возвращаемый тип — конкретный subtype [CommonEvent.Conversation].
*/
/**
* **Default implementation** (читает все events + фильтрует).
*
* Простая реализация через [events] + filterIsInstance. Реализации
* могут override'нуть для эффективности (например, добавить SQL
* `WHERE conversation_id = ?` чтобы не тянуть всё в память), но
* контракт корректен и без override.
*/
fun conversationEvents(after: Instant?, conversationId: String? = null): Flow<CommonEvent.Conversation> =
events(after)
.filterIsInstance<CommonEvent.Conversation>()
.let { filtered ->
if (conversationId == null) filtered
else filtered.filter { it.conversationId == conversationId }
}
/**
* Subscribe на **только agent events** ([CommonEvent.Agent] —
* создание/удаление/переименование диалога).
*
* Семантика `after` идентична [events] (catchup + live).
* Возвращаемый тип — конкретный subtype [CommonEvent.Agent].
*
* Полезно для admin-дашборда, который хочет видеть только lifecycle
* диалогов без деталей ходов.
*/
/**
* **Default implementation** (читает все events + фильтрует по типу).
*
* Простая реализация через [events] + filterIsInstance. Реализации
* могут override'нуть для эффективности (например, читать только agent
* row'ы из БД), но контракт корректен и без override.
*/
fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> =
events(after).filterIsInstance<CommonEvent.Agent>()
/**
* Date **стартовой точки** буфера.
*
* - Если буфер не пуст → `date` самого старого буферизованного event'а.
* - Если буфер пуст → текущее время (`Clock.System.now()` на момент вызова).
*
* **Семантика "now если пусто"** важна: позволяет клиенту безопасно
* подписаться на [events](after = earliest) сразу — он получит только
* новые live event'ы, без ложного catchup. Если бы возвращалось
* `Instant.DISTANT_PAST` или `null` (с проверкой), клиент мог бы
* ошибочно подписаться на несуществующий catchup и зависнуть в ожидании.
*
* **Используется клиентом для gap detection**:
* - `lastSeen < earliest` → есть дыра в покрытии, нужен fallback
* в message store за диапазоном `[lastSeen, earliest)`.
* - `lastSeen >= earliest` → всё доступно через [events](after),
* fallback не нужен.
* - `lastSeen == earliest` → OK, первый live event будет > earliest.
*
* **Edge case**: клиент, подключившийся до того как store увидел хоть
* один event, получает `earliest ≈ now`. Его `lastSeen` будет < earliest
* — адаптируется в первом же poll'е и пойдёт через fallback если
* сообщения audit log существуют (для consistency с прошлым).
*
* Suspend потому что в persistent impl'ах требует SQL query (`MIN(date)`
* или `Clock.now()` для пустого буфера).
*/
suspend fun earliestEventDate(): Instant
override fun close()
}

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