51 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Дополнительно: игнорируем локальный opencode config.json.
2026-09-21 23:10:19 +03:00
subochev 68543357c2 feat(memory): migrate EmbeddingProvider to KMP-compatible TextEmbeddingExecutor, add :memory-md-vector, and hybrid backend support
ci / JVM build + tests (push) Failing after 11s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 10s
- Replaced `EmbeddingProvider` with cross-platform `TextEmbeddingExecutor` for native target compatibility.
- Introduced `:memory-md-vector` module combining vector-cache and `.md` file-based memory systems (`hybrid` backend).
- Updated `SiglipEmbeddingProvider` to use KMP `TextEmbeddingExtractor` and streamlined compatibility via `asExecutor`.
- Added hybrid memory backend to `standalone`, supporting `.md` reconciliation with vector-cache for semantic
2026-09-21 12:28:09 +03:00
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
Porfiry c486c7f9ab auth: своя Bearer-авторизация agentik (сервер + клиент + standalone)
ci / JVM build + tests (push) Successful in 6m11s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 33s
Добавлена собственная авторизация по токену. Это ОТДЕЛЬНАЯ подсистема:
библиотека A2A (pw.binom.a2a) имеет свой независимый token, общих типов
и общей логики не вводится.

Поведение по умолчанию не меняется: token = null -> авторизация выключена,
сервер открыт (обратная совместимость), CLI/TUI не затронуты.

Сервер (:server):
- новый route-scoped плагин BearerTokenPlugin (BearerTokenConfig);
- agentikAgent(agent, path, token) ставит плагин на всё поддерево /agentik,
  когда token != null; иначе плагин не устанавливается;
- при несовпадении заголовка Authorization: Bearer <token> -> 401 Unauthorized;
- /health всегда открыт (liveness для балансировщика).

Клиент (:client):
- defaultAgentikHttpClient(token) навешивает Authorization: Bearer <token>
  через DefaultRequest на весь HttpClient -> накрывает все 10 вызовов и оба SSE;
- AgentikAgent(id, baseUrl, token, httpClient) — token необязательный,
  9 существующих мест создания агента не тронуты.

Standalone:
- AgentSection.authToken (env AGENTIK_TOKEN) -> /agentik;
- AgentSection.a2aToken (env AGENTIK_A2A_TOKEN) -> /a2a;
- два независимых значения, связи между ними нет.

Тесты: BearerTokenTest (5), BearerHeaderTest (3) — 401 без токена и с чужим,
200 с верным, /health открыт, null -> открыто. Мутационная проверка пройдена.
2026-09-19 20:58:52 +03:00
subochev 9d310c5fd0 ci: убрать upload-artifact@v4 — GHESNotSupportedError валил джоб после зелёных тестов
ci / JVM build + tests (push) Successful in 6m5s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 32s
2026-09-18 22:19:39 +03:00
subochev 741ad8963d ci: UTF-8 локаль + ASCII-дефис в именах тестов; release через общий publish action
ci / JVM build + tests (push) Failing after 2m44s
- ci.yml/release.yml: LANG/LC_ALL=C.UTF-8 — иначе Kotlin-компилятор падает
  с InvalidPathException на именах тестов с типографским тире (LANG=C → ASCII)
- имена тестов: типографское тире U+2014 заменено на ASCII-дефис (16 шт)
- release.yml переведён на общий composite-action subochev/devops/publish@main
  (как у asr-kmp/litert-kmp/embedder-kmp); версия = имя тега релиза
2026-09-18 22:15:51 +03:00
subochev 2634e0e204 chore: ignore .tasks/ (internal review scratch dir)
ci / JVM build + tests (push) Failing after 2m0s
2026-09-18 21:11:15 +03:00
subochev ac5d209fce refactor(standalone): extract modules, event-driven background, AppConfig
ci / JVM build + tests (push) Failing after 2m5s
Standalone refactor — modularity + correctness improvements after
STANDALONE-REVIEW findings. Touches ~30 files. Build green, 178 tests pass.

(1) Module extractions — generic components out of :standalone:

  • :llm-tools (new KMP module, package pw.binom.agentik.llm.tools)
    - LlmReflector, SkillMiner, LlmMemoryReviewer, LiteLlmContextCompactor
    - Parsers: ReflectionParser, SkillMiningParser, ReviewDecisionParser
    - Prompts: ReflectionPrompts, SkillMiningPrompts, ReviewPrompts

  • :mcp-bridge (new JVM module, package pw.binom.agentik.mcp.bridge)
    - McpConfig, McpRegistry, McpLiteToolAdapter

  • NamedTool moved from :standalone to :agent-toolsets/commonMain
    - Generic (name + LiteTool) wrapper, used by both :mcp-bridge
      and :standalone's tool dispatcher

  :standalone loses ~1400 lines, depends on the two new modules.

(2) Background work → event-driven (no more interval-polling):

  • New :standalone/agent/BackgroundEvents.kt — internal event bus:
    - ToolCallEvent.Succeeded/Failed (emitted by ToolDispatcher after invoke)
    - CompactionEvent.Triggered (emitted by CompactionCoordinator pre-delete)
    - ConversationLifecycleEvent.Closing (emitted by ConversationLoop.close)

  • BackgroundScheduler rewritten as event subscriber:
    - On Closing: final reflection + skill mining (last-chance extraction)
    - On Compaction (turnsToDelete > 10): skill mining (debounced 60s)
    - On ToolFailure x2 in 60s window: reflection (debounced 5min)
    - Dropped: maybeScheduleReview/Reflection/SkillMining (interval-based)
    - Dropped config: memoryReviewInterval, reflectionInterval, skillMiningInterval

  • ToolDispatcher emits ToolCallEvent after each invoke.
  • CompactionCoordinator emits CompactionEvent before workingMemory.compact().
  • ConversationLoop.close() emits Closing BEFORE agentScope.cancel() so the
    subscription gets to run final reflection/mining.

  Net effect: typical 30-turn conversation runs ~38 LLM calls (was: 30 main +
  3 review + 3 reflection + 2 mining). With event-driven, review/mining only fire
  when their triggers actually make sense (compaction about to delete, or
  conversation closing).

(3) AppConfig single source of truth:

  • Replaces AgentikConfig + LlmConfig.fromEnv + McpConfig.fromEnv with one
    AppConfig.fromEnv() that reads all ~25 env vars in a single pass.
  • Sections: AgentSection, LlmSection, McpSection, MemorySection,
    EmbeddingSection, ReflectionSection, SkillMiningSection, DebugSection.
  • OPENAI_CONTEXT_WINDOW / AGENTIK_GOOGLE_CONTEXT_WINDOW no longer
    read twice (was a bug per STANDALONE-REVIEW E3).

(4) Other fixes inherited from earlier waves:

  • Hardening — size caps on user-input boundaries:
    MAX_MEMORY_CONTENT_LEN=32KB, MAX_SKILL_BODY_LEN=64KB,
    MAX_MCP_CONFIG_BYTES=1MB, MAX_A2A_REPLY_LEN=10MB, MAX_PORT=65535,
    blank-rejection in LlmConfig.requireEnv, URL/command validation.
  • Single scope — :standalone/agent/ConversationLoop has one
    agentScope (was: scope + backgroundScope).
  • liteConvRef race fix — capture-then-use pattern replaces !!-after-read;
    close() + runTurn.finally race on LiteConv JNI handled via
    AtomicReference.getAndSet.
  • SkillMiner.maxTurns / LlmReflector.maxTurns exposed as public (needed
    by BackgroundScheduler for prompt sizing).
  • Tests: MemoryWiringTest updated for new compaction-triggered review
    behavior; all parser/test imports updated for new packages.

Test results: 178/178 in :standalone, 36/36 in :agent-toolsets — all green.
2026-09-18 20:43:54 +03:00
subochev 25771a0c33 docs(diagrams): agent architecture overview with pre-rendered SVG
ci / JVM build + tests (push) Failing after 1m57s
PlantUML diagrams for future agent architecture (Android, multi-user
chat, sub-agents, A2A):
- 01-module-layers.md — целевая модульная структура
- 02-agent-composition.md — AgentBuilder DSL + MemoryBackend.exposesTools()
- 03-multi-user-chat.md — mention-detection sequence
- 04-sub-agents.md — spawnChild + Flow<SubAgentEvent> + A2A
- 05-android-stack.md — что меняется на Android vs Standalone

Каждый .md включает пред-рендеренный SVG (показывается во всех markdown
viewers без PlantUML plugin) + PlantUML source в code block (для
редактирования). SVG нужен потому что PlantUML требует Graphviz dot
для рендеринга — без него IntelliJ/VSCode выдают ошибку.

Регенерация SVG после правки PlantUML-source:
  docker run --rm -v "$PWD:/work" plantuml/plantuml -tsvg /work/docs/diagrams/*.md
2026-09-18 20:02:41 +03:00
subochev 78cbe9b463 refactor(standalone): split ChatConversation into components
Decompose 1415-line god class into focused components:
  - ConversationState (shared mutable state)
  - ConversationEvents (SharedFlow + policy)
  - ContextBuilder (prefix/memory helpers)
  - CompactionCoordinator (compaction + LiteConv rebuild)
  - ToolDispatcher (single tool-call execution)
  - BackgroundScheduler (review/reflection/mining triggers)
  - ConversationLoop (orchestrator, implements ProtoConversation)

ChatConversation becomes a typealias. Public API preserved.
2026-09-18 03:00:24 +03:00
279 changed files with 13840 additions and 5875 deletions
+21 -18
View File
@@ -5,6 +5,9 @@
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
# Все env secrets доступны через vars/secrets репозитория — см. начало
# release.yml для требуемых переменных.
#
# :agentik-cli / :agentik-tui исключены из сборки (settings.gradle.kts
# 2026-09-17/21) — соответствующие шаги shadowJar тут НЕ запускаются.
name: ci
on:
@@ -17,6 +20,14 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# UTF-8 обязателен: в именах тестов есть типографские символы (—), а Kotlin-компилятор
# создаёт .class-файлы с именем теста. При LANG=C sun.jnu.encoding = ASCII, и компилятор
# падает с "InvalidPathException: Malformed input or input contains unmappable characters"
# (проверено локально: LANG=C → BUILD FAILED, LANG=C.UTF-8 → BUILD SUCCESSFUL).
env:
LANG: C.UTF-8
LC_ALL: C.UTF-8
jobs:
build-jvm:
name: JVM build + tests
@@ -59,21 +70,13 @@ jobs:
test -f standalone/build/libs/standalone-*-all.jar \
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
- name: Build :agentik-cli shadowJar
shell: bash
run: |
./gradlew :agentik-cli:shadowJar \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
- name: Upload shadowJars
uses: actions/upload-artifact@v4
with:
name: agentik-jars
path: |
standalone/build/libs/standalone-*-all.jar
agentik-cli/build/libs/agentik-cli-*-all.jar
if-no-files-found: error
retention-days: 7
# Шага "Build :agentik-cli shadowJar" здесь нет: :agentik-cli исключён из
# settings.gradle.kts (2026-09-21). :agentik-tui — тоже исключён (2026-09-17).
# Когда/если оба вернутся, добавим отдельные шаги по аналогии с :standalone.
#
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
# и валит весь джоб уже ПОСЛЕ успешной сборки и зелёных тестов.
# У соседних репо (asr-kmp, litert-kmp) артефакты наружу тоже не выгружаются —
# проверка сборки ограничивается test -f на jar (шаги выше).
+19 -46
View File
@@ -8,10 +8,13 @@
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
# отдельно от этого workflow.
#
# Требуемые Gitea Action Variables:
# BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/
# Требуемые Gitea Action Secrets:
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
# Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
# публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
# версия берётся из тега каждый раз.
#
# Публикация выполняется общим composite-action'ом subochev/devops/publish@main
# (тот же, что у asr-kmp / litert-kmp / embedder-kmp / a2a-protocol) — credentials
# BINOM_REPO_* берутся им из Gitea Action Variables (owner_id=0, глобальные).
name: release
on:
@@ -22,6 +25,15 @@ concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# UTF-8 обязателен: генерация POM/Kotlin-метаданных и имена тестовых классов
# содержат не-ASCII символы; при LANG=C sun.jnu.encoding = ASCII и сборка
# падает с "InvalidPathException: Malformed input or input contains unmappable
# characters" (проверено локально 19.09.2026: LANG=C → BUILD FAILED,
# LANG=C.UTF-8 → BUILD SUCCESSFUL).
env:
LANG: C.UTF-8
LC_ALL: C.UTF-8
jobs:
publish-libraries:
name: Publish KMP libraries → caffeine Nexus
@@ -31,46 +43,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
- name: Publish libraries (all KMP targets, all modules) to Nexus
uses: https://git.binom.pw/subochev/devops/publish@main
with:
java-version: '21'
distribution: 'adopt'
- name: Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
.gradle
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-agentik-
- name: Publish libraries (all KMP targets, all modules)
shell: bash
env:
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
run: |
# Gitea Actions (Forgejo-based) экспонирует env-переменные под
# GITHUB_-префиксом: GITHUB_REF_NAME = "v0.1.0" для tag-trigger'а.
# Внутри bash подставляем через $GITHUB_REF_NAME (а не
# ${GITEA_REF_NAME} — Forgejo этого не подставляет).
#
# Версия = имя тега (с trim'ом опционального префикса 'v'), чтобы
# тег "1" публиковался как pw.binom.agentik:<module>:1. CICD не
# хардкодит версию — берёт её из тега каждый раз.
TAG="$GITHUB_REF_NAME"
VERSION="${TAG#v}"
echo "Publishing version: ${VERSION}"
./gradlew \
"-Pversion=${VERSION}" \
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
publish \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
version: ${{ gitea.ref_name }}
+5
View File
@@ -18,8 +18,13 @@ out/
# Local tooling (Magic Context, IDE plugins, MCP configs)
.cortexkit/
# opencode CLI local config (per-machine, не коммитим)
config.json
.veai/
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
.tasks/
# Runtime / test artifacts
agentik.db
agentik.db-shm
+7 -6
View File
@@ -18,8 +18,9 @@ agentik/
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
├── storage-inmemory/ in-memory реализация для тестов и Android
├── storage-sqlite/ SQLite реализация для production
│ (исторический, см. journal-api / context-api / reflection-api ниже)
├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
@@ -89,9 +90,9 @@ curl http://localhost:8080/health
- [`:memory-api`](memory-api/README.md) — контракт памяти.
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
- [`:storage-core`](storage-core/README.md) — контракт storage.
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
- [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
- ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
- ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
## Где смотреть версии
@@ -124,7 +125,7 @@ SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппирован
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
- `.gitea/ci.yml` — PR-build, прогон тестов, проверка
shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
+42
View File
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :proto — read-only Agent interface, который MutableAgent расширяет.
// Через api(), иначе downstream-impl ChatAgent не сможет
// override suspend-методы Agent.
api(project(":proto"))
// :memory-api — typealias ConversationTurn на memory-api одноимённый
// класс, иначе пер-конво компоненты (skill mining, reflection) не
// смогут передать его в SkillMiner.mine() напрямую.
api(project(":memory-api"))
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
api(libs.litert.api)
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
// listener) — оставлен как api, чтобы downstream не забывал объявить.
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.agent
/**
* Нашлёпка поверх [MutableAgent].
*
* Компонент сам регистрирует в агенте свои capability-провайдеры
* при [install] и снимает их при [uninstall]. Агент не знает заранее
* ни о структуре компонента, ни о его провайдерах — это просто
* хук для свободной композиции.
*
* Ktor-style API:
* ```
* val agent = ChatAgent(...)
* .install(SkillComponent(store, miner))
* .install(ReflectionComponent(reflectionStore, reflector))
* .install(MemoryComponent(memorySystem))
* ```
*
* Контракт:
* - [install] **синхронен**: компонент добавляет свои провайдеры в
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
* фоновые корутины — компонент запускает их через свой собственный
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
*/
interface Component {
/**
* Вызывается агентом при [MutableAgent.install].
*
* Типичные действия: добавить [SystemPromptProvider] в
* `agent.systemProviders`, добавить [ToolProvider] в
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
*/
fun install(agent: MutableAgent)
/**
* Вызывается агентом при [MutableAgent.uninstall] или при
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
* которые добавил в [install], и остановить фоновые джобы.
*/
fun uninstall(agent: MutableAgent)
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.agent
/**
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
*
* [MutableAgent] при создании/закрытии разговора вызывает
* [attachConversation] / [detachConversation] на каждом компоненте,
* реализующем этот интерфейс. Внутри компонент хранит
* [ConversationHandle] (или контекст вокруг него) и подписывается на
* нужные события.
*
* Компонент без [ConversationAware] остаётся чисто agent-level — он
* не получает per-conversation хуков.
*/
interface ConversationAware {
fun attachConversation(handle: ConversationHandle)
fun detachConversation(handle: ConversationHandle)
}
/**
* Минимальное окно в разговор, которое компонент видит через
* [ConversationAware]. Содержит только то, что нужно большинству
* per-conversation компонентов:
* - идентификатор (для подписки на события),
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
* - последние N turns (для LlmReflector / SkillMiner).
*
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
* чтобы компонент не лез в чужие обязанности.
*/
interface ConversationHandle : AutoCloseable {
val id: String
val isTemporal: Boolean
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
suspend fun recentTurns(limit: Int): List<ConversationTurn>
override fun close()
}
/**
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
* компоненты (skill mining, reflection) могли передавать его напрямую
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
* конвертации.
*/
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
@@ -0,0 +1,78 @@
package pw.binom.agentik.agent
import pw.binom.agentik.proto.Agent
/**
* Настраиваемая версия [Agent]: расширяет публичный contract агента
* install/uninstall-механикой компонентов ([Component]).
*
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
*
* Контракт:
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
*
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
* позже, в v1 держим KISS.
*/
interface MutableAgent : Agent {
/**
* Провайдеры секций system prompt, регистрируются компонентами через [install].
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
* system prompt конкретной беседы; возвращает `null`, если у него нет
* релевантной секции для данного контекста.
*
* Изменяется **только внутри `Component.install(this)` /
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
* напрямую в список не лезет.
*/
val systemProviders: MutableList<SystemPromptProvider>
/**
* Провайдеры tools, регистрируются компонентами через [install].
* [ToolProvider.tools] вызывается при формировании набора тулов
* для конкретной беседы; компонент решает сам, какие тулы отдавать
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
*/
val toolProviders: MutableList<ToolProvider>
/**
* Устанавливает [component] в агент: `component.install(this)` +
* агент запоминает компонент, чтобы при [close] корректно его снять.
*
* Возвращает `this` — для fluent-цепочек:
* ```
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
* ```
*/
fun install(component: Component): MutableAgent
/**
* Снимает [component]: `component.uninstall(this)` + забывает.
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
*/
fun uninstall(component: Component): MutableAgent
/**
* Оповещает все установленные компоненты, реализующие [ConversationAware],
* о появлении нового разговора. Компонент может подписаться на события,
* запустить фоновые задачи, проиндексировать turns и т.п.
*/
fun attachConversation(handle: ConversationHandle)
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
fun detachConversation(handle: ConversationHandle)
/**
* Освобождает ресурсы агента и снимает все установленные компоненты
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
* первым). Idempotent.
*/
override fun close()
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.agent
/**
* Провайдер одной секции system prompt конкретной беседы.
*
* Вызывается [MutableAgent] при каждом построении system prompt
* (на старте беседы и после значимых изменений контекста). Возвращает
* либо markdown-строку секции (будет вставлена в system prompt в порядке
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
* либо `null`, если у провайдера нет релевантной секции для данного
* контекста (например, skill-каталог пуст).
*
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
* страх и риск, **не** рекомендуется в v1).
*/
fun interface SystemPromptProvider {
/**
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
*
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
* если провайдер хочет делать per-conversation разделение, он может.
*/
fun getSection(conversationId: String): String
}
@@ -0,0 +1,33 @@
package pw.binom.agentik.agent
import pw.binom.litert.LiteTool
/**
* Провайдер набора тулов конкретной беседы.
*
* Вызывается [MutableAgent] при формировании списка тулов, доступных
* модели в данной беседе (на старте и при пересборке после существенных
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
* из `describe()` / `invoke()` того же объекта.
*
* Не-suspend: типичная реализация строит список тулов из in-memory state
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
* к state компонент использует свой собственный scope и кэш.
*
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
* лишним слоем и удалён.
*/
fun interface ToolProvider {
/**
* Возвращает список тулов, доступных модели в беседе [conversationId].
*
* Провайдер может делать per-conversation фильтрацию (например, скрывать
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
* пустой список.
*/
fun getTools(conversationId: String): List<LiteTool>
}
+1 -1
View File
@@ -77,7 +77,7 @@ budget exhaustion, registry filter, parallel dispatch.
## Чего здесь НЕТ
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
- Никакого persistent storage. Опирается на контракт `ContextStore`
(см. `:storage-core`).
## Текущий статус
+6 -2
View File
@@ -21,11 +21,15 @@ kotlin {
sourceSets {
commonMain.dependencies {
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
api(project(":storage-core"))
api(project(":journal-api"))
api(project(":reflection-api"))
api(project(":context-api"))
api(project(":agent-api"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
api(libs.litert.tools.kotlinx.serialization)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
@@ -1,5 +1,6 @@
package pw.binom.agentik.toolsets
import kotlinx.serialization.Serializable
import pw.binom.litert.LiteTool
/**
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
*/
class DisableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
val tool: LiteTool = liteToolSuspend<DisableArgs>(
name = NAME,
description = "Deactivate a toolset by name. Its tools become unavailable.",
) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
internal suspend fun invoke(args: DisableArgs): String {
val name = args.name
val toolset = registry.findByName(name)
if (toolset != null) {
// Единообразный ответ независимо от текущего состояния.
registry.deactivate(name)
return "Toolset '$name' deactivated."
}
// Неизвестный — перечисляем активные (что можно деактивировать)
val actives = registry.activeNames()
return if (actives.isEmpty()) {
"Toolset '$name' not found. No toolsets to deactivate."
@@ -39,11 +40,10 @@ class DisableToolsetTool(private val registry: ToolsetRegistry) {
}
}
@Serializable
internal data class DisableArgs(val name: String)
companion object {
const val NAME: String = "disable_toolset"
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
""".trimIndent()
}
}
@@ -1,8 +1,6 @@
package pw.binom.agentik.toolsets
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.Serializable
import pw.binom.litert.LiteTool
/**
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
*/
class EnableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
val tool: LiteTool = liteToolSuspend<EnableArgs>(
name = NAME,
description = "Activate a toolset by name to access its tools.",
) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
internal suspend fun invoke(args: EnableArgs): String {
val name = args.name
val toolset = registry.findByName(name)
if (toolset != null) {
val wasActive = registry.isActive(name)
registry.activate(name)
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
}
// Неизвестный — перечисляем доступные к активации (inactives)
val inactives = registry.inactiveNames()
return if (inactives.isEmpty()) {
"Toolset '$name' not found. No toolsets available for activation."
@@ -42,23 +41,10 @@ class EnableToolsetTool(private val registry: ToolsetRegistry) {
}
}
@Serializable
internal data class EnableArgs(val name: String)
companion object {
const val NAME: String = "enable_toolset"
/**
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
* Соответствует litert-kmp формату LiteTool.describe().
*/
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
""".trimIndent()
}
}
/**
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
* Возвращает null если отсутствует или не строка.
*/
internal fun parseName(argsJson: String): String? = runCatching {
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
}.getOrNull()
@@ -2,9 +2,10 @@ package pw.binom.agentik.toolsets
import kotlinx.coroutines.runBlocking
import pw.binom.litert.LiteTool
import pw.binom.litert.tools.kotlinx.serialization.liteTool
/**
* Адаптер из suspend-handler'а в синхронный [LiteTool].
* Обёртка из suspend-handler'а в синхронный [LiteTool].
*
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
*
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
* который генерит JSON-схему из `@Serializable Args` через
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
* тулов, чьи описания не получается выразить через `Args` (например, динамические
* JSON Schema, приходящие со стороны).
*
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
*/
internal class SyncLiteTool(
override val name: String,
private val describeJson: String,
private val handler: suspend (String) -> String,
) : LiteTool {
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
}
/**
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
* как API-точка чтобы внешний код не зависел от internal-имени класса.
* Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
* JSON-схема генерится автоматически из `Args.descriptor`,
* а сырая строка аргументов десериализуется в типизированный [Args].
*
* Использование:
* ```
* val t: LiteTool = liteToolSuspend<MyArgs>(name = "foo", description = "...") { args ->
* suspendBlock(args) // MyArgs уже распарсен
* }
* ```
*
* Реализация: под капотом используется [pw.binom.litert.tools.kotlinx.serialization.liteTool] —
* его sync-handler запускает наш suspend-handler в [runBlocking].
*/
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
SyncLiteTool(describeJson, handler)
inline fun <reified Args> liteToolSuspend(
name: String,
description: String = "",
noinline handler: suspend (Args) -> String,
): LiteTool = liteTool<Args>(
name = name,
description = description,
) { args ->
runBlocking { handler(args) }
}
@PublishedApi
internal val invocationJson: kotlinx.serialization.json.Json = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
isLenient = false
coerceInputValues = true
explicitNulls = false
}
@@ -0,0 +1,103 @@
package pw.binom.agentik.toolsets
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.litert.LiteTool
/**
* Подключает механику toolsets к агенту:
* - [ToolsetRegistry] (per-component instance — раньше жил в ChatAgent).
* - Тулы [EnableToolsetTool] и [DisableToolsetTool] всегда доступны — модель
* ими переключает состояние.
* - Тулы активных тулсетов — динамически: после `enable_toolset(name=X)`
* X.tools становятся видны через [ToolProvider.getTools] уже на
* следующем turn'е.
* - Секция системного промпта — список активных/неактивных тулсетов,
* чтобы модель знала что включено.
*
* Один [ToolsetComponent] на агента. Шарится между беседами через общий
* [MutableAgent] (все conversations читают один [ToolsetRegistry]).
*
* `install(agent)` идемпотентно. `uninstall(agent)` снимает оба провайдера
* по типу (см. [ToolsetToolProvider], [ToolsetSystemProvider]).
*/
class ToolsetComponent(
private val contributions: List<ToolsetContribution>,
) : Component {
/**
* Реестр тулсетов, владеет [ToolsetComponent]. `private` — наружу не светится,
* чтобы никто не дёргал его мимо `enable_toolset`/`disable_toolset` тулов.
*/
private val registry: ToolsetRegistry = ToolsetRegistry(contributions)
private var provider: ToolsetToolProvider? = null
override fun install(agent: MutableAgent) {
val p = ToolsetToolProvider(registry, contributions)
agent.toolProviders.add(p)
provider = p
agent.systemProviders.add(ToolsetSystemProvider(registry))
}
override fun uninstall(agent: MutableAgent) {
provider?.let { agent.toolProviders.remove(it) }
agent.systemProviders.removeAll { it is ToolsetSystemProvider }
}
}
/**
* Возвращает тулсет-тулы в зависимости от текущего состояния реестра:
* - `enable_toolset` / `disable_toolset` — всегда.
* - Тулы активных тулсетов — те, что перечислены в [ToolsetRegistry.activeNames].
*
* Snapshot собирается на каждом вызове [getTools] — диспетчер видит свежее
* состояние после `enable_toolset` уже на следующем turn'е.
*/
class ToolsetToolProvider(
private val registry: ToolsetRegistry,
private val contributions: List<ToolsetContribution>,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> = buildList {
add(EnableToolsetTool(registry).tool)
add(DisableToolsetTool(registry).tool)
// Активные тулсеты — добавляем их тулы в общий пул. Это синхронная
// версия (lock-free snapshot), потому что `getTools` вызывается
// синхронно из `collectTools()`; `active` сам по себе Concurrent-Set
// через Mutex в реестре (все мутации — через activate/deactivate).
val active = runBlockingSnapshot()
contributions.filter { it.name in active }.forEach { c ->
c.tools.forEach { add(it.tool) }
}
}
/**
* Снимает снимок активных имён без suspend-блокировки.
* ToolsetRegistry.activeNames() — suspend, но его можно обойти если
* вычислить через прямой snapshot — для простоты используем runBlocking.
* Это всё равно вызывается на каждый turn, но мьютекс короткий.
*/
private fun runBlockingSnapshot(): Set<String> = kotlinx.coroutines.runBlocking {
registry.activeNames().toSet()
}
}
/**
* Секция системного промпта с описанием доступных тулсетов:
* - `*active*` — что уже подключено.
* - `*inactive*` — что доступно через `enable_toolset`.
*/
class ToolsetSystemProvider(
private val registry: ToolsetRegistry,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String {
val activeNames = kotlinx.coroutines.runBlocking { registry.activeNames() }.toSet()
val all = registry.all()
val active = all.filter { it.name in activeNames }
val inactive = all.filter { it.name !in activeNames }
return SystemPromptToolsetSection.render(active = active, inactive = inactive) ?: ""
}
}
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
* Контекст, который тулсеты получают при активации.
*
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
* добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
* могли читать/писать сообщения и память.
*
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class DisableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok"
}
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = disable.invoke("""{"name":"media"}""")
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
assertEquals("Toolset 'media' deactivated.", r)
assertEquals(false, reg.isActive("media"))
}
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
val r = disable.invoke("""{"name":"media"}""")
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
assertEquals("Toolset 'media' deactivated.", r)
}
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
))
reg.activate("a")
reg.activate("b")
val r = disable.invoke("""{"name":"unknown"}""")
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
}
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
))
val r = disable.invoke("""{"name":"unknown"}""")
val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
}
@Test
fun `missing name argument returns error message`() = runTest {
val (disable, _) = harness(emptyList())
val r = disable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
}
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class EnableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok"
}
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
))
val r = enable.invoke("""{"name":"media"}""")
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' activated.", r)
assertEquals(true, reg.isActive("media"))
}
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = enable.invoke("""{"name":"media"}""")
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' already active.", r)
}
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
ToolsetContribution("b", "y", emptyList()),
ToolsetContribution("c", "z", emptyList()),
))
val r = enable.invoke("""{"name":"unknown"}""")
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
}
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
ToolsetContribution("a", "x", emptyList()),
))
reg.activate("a")
val r = enable.invoke("""{"name":"unknown"}""")
val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
}
@Test
fun `missing name argument returns error message`() = runTest {
val (enable, _) = harness(emptyList())
val r = enable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
}
@@ -11,6 +11,7 @@ import kotlin.test.assertTrue
class ToolsetDispatchPolicyTest {
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response
}
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
class ToolsetRegistryTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok:$name"
}
+1
View File
@@ -41,6 +41,7 @@ kotlin {
implementation(libs.kotlinx.cli)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio)
}
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
// весь код, включая 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 pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
val id by argument(ArgType.String, description = "ID диалога")
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)
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.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
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)
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))
if (convs.isEmpty()) {
println("(no conversations)")
@@ -3,13 +3,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
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)
println(conv.id)
}
@@ -2,6 +2,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
@@ -9,7 +10,7 @@ class ConvRenameSubcommand : ConvSubcommand("rename", "Переименоват
val title by argument(ArgType.String, description = "Новое название")
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 {
println("conversation not found: $id")
return@runBlocking
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
val id by argument(ArgType.String, description = "ID диалога")
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 {
println("conversation not found: $id")
return@runBlocking
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
val id by argument(ArgType.String, description = "ID диалога")
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 {
println("conversation not found: $id")
return@runBlocking
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content
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)
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 {
println("conversation not found: $id")
return@runBlocking
@@ -7,9 +7,10 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
@@ -17,7 +18,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
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 {
println("conversation not found: $id")
return@runBlocking
@@ -26,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
// Подписываемся на поток событий ДО send: события, отправленные
// до подписки, не реплеятся (shared-flow без replay).
val eventsJob = launch {
conv.events(Instant.DISTANT_PAST)
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
// onEach печатает и терминальный event, takeWhile лишь
// завершает сбор после него.
.map { it.event }
.onEach { ev -> emit(ev) }
.takeWhile { ev -> !isTerminal(ev) }
.collect { }
@@ -52,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
is Event.End -> println("event End")
is Event.Interrupted -> println("event Interrupted")
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
@@ -7,7 +7,7 @@ import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.outbox.Event
import kotlin.coroutines.CoroutineContext
import kotlin.time.Instant
@@ -46,7 +46,11 @@ internal class TuiBackend(
state.postSystem("подключено к ${state.config.server}")
scope.launch {
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) {
// штатная отмена при закрытии UI
} 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) {
eventsJob?.cancel()
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.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant
@@ -25,6 +27,19 @@ internal class FakeAgent(
private set
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 {
createCount++
val c = conversationFactory()
@@ -38,10 +53,7 @@ internal class FakeAgent(
override suspend fun deleteConversation(id: String): Boolean =
conversations.removeAll { it.id == id }
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
conversations.toList()
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
override suspend fun renameConversation(id: String, title: String?): Instant? = null
}
/**
@@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import pw.binom.agentik.outbox.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -170,7 +170,7 @@ class TuiBackendTest {
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
runCurrent()
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
@@ -205,7 +205,7 @@ class TuiBackendTest {
}
@Test
fun `onUserMessage does not swallow exceptions — state stays consistent`() = runTest {
fun `onUserMessage does not swallow exceptions - state stays consistent`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
+2 -2
View File
@@ -47,8 +47,8 @@ val moduleDescriptions: Map<String, String> = mapOf(
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
"storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
"storage-sqlite" to "agentik :storage-sqlite — исторический модуль (deleted 2026-09-22; ksqlite-реализации теперь живут в :journal-ksqlite / :context-ksqlite / :reflection-ksqlite).",
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
+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 фабрики.
- `AgentClient` — список и lifecycle диалогов.
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
`getMessages()`, `rename()`, `close()`.
- Внутренний парсер SSE → `Flow<Event>`.
- `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
`Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
- `Agent`: `createConversation` / `getConversation` / `getConversations` /
`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,
с теми же типами, что и сервер. Один и тот же клиент работает на
JVM, iOS, macOS, Linux, Windows.
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
## Где используется
- `:agentik-cli` — REPL.
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
## Как подключить
## Подключение
```kotlin
// build.gradle.kts
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:client:0.1.0")
dependencies {
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) }
}
// ваш код:
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
val conv = agent.createConversation(title = "test")
conv.send(listOf(Content.Text("hello"))).collect { event ->
when (event) {
is Event.AppendText -> print(event.body)
is Event.End -> println("\n--- end ---")
is Event.Error -> error("agent error: ${event.message}")
else -> Unit
// История — теперь из кэша, без HTTP:
val history = cache.list(conv.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
history.forEach { rec ->
when (rec) {
is pw.binom.agentik.journal.MessageRecord.UserMessage -> print("user> ${rec.content.text()}")
is pw.binom.agentik.journal.MessageRecord.AssistantMessage -> print("agent> ${rec.content.text()}")
is pw.binom.agentik.journal.MessageRecord.ToolCall -> print("[tool: ${rec.toolName}]")
is pw.binom.agentik.journal.MessageRecord.ToolResult -> print("[result]")
is pw.binom.agentik.journal.MessageRecord.Error -> print("[error: ${rec.message}]")
}
}
```
## Версии
Шаблон "remote.listFlow → local.append" работает с любым
`MutableJournalStore` (см. `:journal-api`). Это и есть кэширование
"без геморроя".
`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
// список диалогов
agent.getConversations().collect { println(it.id to it.title) }
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
// live-подписка на события отдельного диалога
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
// прерывание текущего хода
conversation.interrupt()
val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
// история
conversation.getMessages(offset = 0).collect { msg ->
when (msg) {
is Message.UserMessage -> println("user: ${msg.content}")
is Message.AssistantMessage -> println("assistant: ${msg.content}")
else -> Unit
conv.events(after = kotlin.time.Clock.System.now()).collect { ev ->
when (ev) {
is Event.AppendText -> print(ev.body) // streaming чанки
is Event.End -> println("\n--- end ---")
is Event.Error -> error("agent error: ${ev.message}")
else -> Unit
}
}
```
## История с локальным кэшем
Главный паттерн: **клиент держит свой `MutableJournalStore` и периодически
(или разово) синхронизирует с удалённым через `listFlow`**. Дальше всё
чтение истории — из локального кэша.
`InMemoryJournalStore` — это `MutableJournalStore`, ты можешь реализовать
свой (например с персистентностью в SQLite/JSON/whatever) — главное чтобы
реализовывал интерфейс.
```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant
class ChatSession(
private val agent: pw.binom.agentik.proto.Agent,
val conversationId: String,
) : AutoCloseable {
// Локальный кэш. Замените InMemoryJournalStore на свой, если нужна
// персистентность (SQLite/JSON/etc.) — контракт `MutableJournalStore`
// (модуль `:journal-api`).
val cache = InMemoryJournalStore()
// Подписка на live-события этого диалога — будем обновлять кэш на `End`.
private val scope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() +
kotlinx.coroutines.Dispatchers.Default,
)
init {
// 1. Backfill: забираем всю историю разговора с сервера.
scope.launch {
agent.journal.listFlow(
conversationId = conversationId,
after = Instant.DISTANT_PAST,
).collect { cache.append(it) }
}
// 2. Live: на каждом `End` хода просим у сервера новые записи.
scope.launch {
agent.getConversation(conversationId)!!.events(Instant.DISTANT_PAST).collect { ev ->
if (ev is Event.End) {
val newest = cache.let {
// last-seen курсор — последний createdAt в кэше
it.list(conversationId, Instant.DISTANT_PAST, 0, 1).lastOrNull()?.createdAt
?: Instant.DISTANT_PAST
}
agent.journal.list(conversationId, newest, offset = 0, limit = 100)
.forEach { cache.append(it) }
}
}
}
}
fun history() = kotlinx.coroutines.runBlocking {
cache.list(conversationId, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
}
override fun close() {
scope.cancel()
}
}
// Использование:
val session = ChatSession(agent, conv.id)
// История — из кэша:
session.history().forEach { rec ->
when (rec) {
is MessageRecord.UserMessage -> println("user: ${rec.content.text()}")
is MessageRecord.AssistantMessage -> println("assistant: ${rec.content.text()}")
is MessageRecord.ToolCall -> println("tool-call: ${rec.toolName}")
is MessageRecord.ToolResult -> println("tool-result: ${rec.result}")
is MessageRecord.Error -> println("error: ${rec.message}")
}
}
// Отправить новое сообщение:
session.scope.launch {
agent.getConversation(conversationId)!!.send(listOf(Content.Text("Привет ещё раз")))
}
```
`InMemoryJournalStore` отдаёт `MessageRecord` со всем payload'ом
(текст + tool-call/tool-result + tokens). UI сам решает что показать —
`rec is MessageRecord.UserMessage` для реплик пользователя,
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
## Кэш списка бесед
`agent.conversationStore` — read-only view поверх `conversation`-таблицы
на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
updatedAt, без `Conversation` handle и без флагов image-support).
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
моментально реагировал на создание/удаление/переименование в другой
вкладке.
Подход — тот же **«remote → local snapshot + live-events»**:
```kotlin
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.outbox.AgentEvent
import io.ktor.client.engine.cio.CIO
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш:
// remote.listFlow → local.upsert (snapshot)
// outbox.agentEvents → local.upsert / delete (live)
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
// Кэш уже наполняется в фоне, читать можно сразу:
val all = agent.conversationStore.list(0, Int.MAX_VALUE)
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") }
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched)
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev ->
when (ev) {
is AgentEvent.Created -> println("+ ${ev.conversationId}")
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}")
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})")
is AgentEvent.Deleted -> println("- ${ev.id}")
}
}
```
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
если нужен SQLite/JSON-store.
## Стриминг live-ответа
Для streaming-рендера текущего хода подписывайся на `events()` и
собирай `Event.AppendText`-чанки в свой буфер. Это **не идёт в кэш** —
только для UI-feedback во время хода. После `End` хода запись уже
появится в кэше через refresh-блок выше.
```kotlin
import pw.binom.agentik.proto.Event
agent.getConversation(convId)!!.events(Instant.DISTANT_PAST).collect { ev ->
when (ev) {
is Event.StartResponse -> println("[start]")
is Event.AppendText -> print(ev.body)
is Event.AppendImage -> showImage(ev.body)
is Event.ToolCall -> println("[tool: ${ev.toolName}]")
is Event.ToolResult -> println("[result]")
is Event.End -> println("[end]")
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit
}
}
```
## Прерывание хода
```kotlin
agent.getConversation(convId)!!.interrupt()
```
## Multi-conversation
Один `Agent`, много `ChatSession`:
```kotlin
val sessions = mutableMapOf<String, ChatSession>()
fun open(convId: String): ChatSession =
sessions.getOrPut(convId) { ChatSession(agent, convId) }
fun close(convId: String) {
sessions.remove(convId)?.close()
}
```
Подписка на lifecycle диалогов (`agent.outbox.agentEvents(...)`) +
UI-обновление списка — отдельная задача, решается `Flow<CommonEvent.Agent>`.
## Где `:client` НЕ помогает
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
отдаёт типы и потоки.
- **Персистентность кэша** — `InMemoryJournalStore` и
`InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
`MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
в `:journal-ksqlite` как образец).
- **Нестандартные движковые настройки** — для `requestTimeout`,
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
напрямую.
## Кэш списка бесед
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
в `localStore.upsert(...)`.
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
- `Deleted(id)` → `local.delete(id)`
- `Renamed(id, title)` → `local.rename(id, title)`
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
клиент видит их через SSE и применяет к локальной копии.
**Команды** (создать / переименовать / удалить) идут через `agent`:
```kotlin
// Создать новую беседу:
val conv = agent.createConversation(temp = false) // → POST /conversations
// → server эмитит Created
// → client cache получает Created
// → UI увидит её в списке
// Переименовать:
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
// → server эмитит Renamed
// → client cache обновляет title
// Удалить:
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
// → server эмитит Deleted
// → client cache удаляет запись
```
`conversationStore` доступен **только для чтения**. Это read-only projection
на серверную таблицу `conversation` (id + title + timestamps). Для активной
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
командами `agent.createConversation / deleteConversation / renameConversation`.
### Если хочется своего cache-импла
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:journal-ksqlite` как образец.
```kotlin
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ConversationRecord
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
override suspend fun delete(id: String): Boolean { /* DELETE */ }
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
override suspend fun touch(id: String, now: Instant) { /* UPDATE updatedAt */ }
override fun close() {}
}
```
## Тесты
```
./gradlew :client:jvmTest
```
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
401/404.
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
reconnect / exhausted attempts → Failed / close → cancel).
## Чего здесь НЕТ
## Auto-reconnect для живого outbox
- Никакого LLM-кода. Это просто клиент.
- Никакого persistent state. История хранится у сервера, клиент её
запрашивает через `getMessages` или подписывается через `events`.
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
## Текущий статус
```kotlin
val recon = ReconnectingOutbox(
outbox = agent.outbox, // или HttpEventStore
scope = myScreenScope,
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
)
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
но клиент совместим с любым сервером, который держит wire-контракт
`:server`.
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
scope.launch {
recon.connectionStatus().collect { status ->
when (status) {
is Connecting -> ui.showBanner("connecting...")
is Connected -> ui.hideBanner()
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
is Failed -> ui.showError(status.cause)
}
}
}
// На выходе (например, navigation back):
recon.close() // отменяет background-loop, потоки терминируются
```
Два потока **независимы** — `events()` содержит только `CommonEvent`,
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
"мешающего" `Connecting`/`Disconnected` в потоке событий.
Параметры backoff (см. `BackoffPolicy`):
- `initial` / `max` — границы задержки
- `multiplier` — множитель на каждом шаге
- `jitter` — рандом-разброс (по умолчанию 20%)
- `maxAttempts` — лимит попыток; после — `Failed` + закрытие потока
Если нужен фиксированный delay для тестов — `BackoffPolicy.Fixed(10.milliseconds, attempts = 3)`.
## Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE.
+5 -2
View File
@@ -21,9 +21,11 @@ kotlin {
sourceSets {
commonMain.dependencies {
api(project(":proto"))
api(project(":outbox-api"))
api(project(":journal-api"))
implementation(project(":journal-inmemory"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
api(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
@@ -37,6 +39,7 @@ kotlin {
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.sse)
}
@@ -4,39 +4,42 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.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.AgentEvent
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/**
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
*
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
* POST `/conversations`. Используем `runBlocking` — это одноразовая
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
* решает, что делать.
* HttpClient создаётся внутри из переданного engine и закрывается в [close].
*
* **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
* views на серверные хранилища. Запись — только через команды
* [createConversation] / [deleteConversation] / [renameConversation].
*/
internal class AgentClient(
private val httpClient: HttpClient,
private val baseUrl: String,
override val id: String,
private val baseUrl: String,
private val httpClient: HttpClient,
) : Agent {
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 =
runBlocking {
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
@@ -58,24 +61,17 @@ internal class AgentClient(
return response.status == HttpStatusCode.NoContent
}
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
val snapshots = httpClient.get("$agentUrl/conversations") {
parameter("offset", offset)
parameter("limit", limit)
}.body<List<ConversationSnapshot>>()
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
override suspend fun renameConversation(id: String, title: String?): Instant? {
val response = httpClient.patch("$agentUrl/conversations/$id") {
contentType(ContentType.Application.Json)
setBody(RequestRename(title))
}
if (response.status == HttpStatusCode.NotFound) return null
val rec = response.body<pw.binom.agentik.journal.ConversationRecord>()
return rec.updatedAt
}
override fun events(after: Instant): Flow<AgentEvent> = flow {
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
}
override fun close() {
httpClient.close()
}
}
@@ -1,46 +1,173 @@
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 kotlin.time.Instant
/**
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
* (модуль `:server`).
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
*
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
* снаружи — всё под капотом.
*
* ```
* val client = AgentikAgent(
* id = "my-agent",
* val agent = AgentikAgent(
* id = "my-client",
* 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.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] (платформо-зависимый
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
* Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
* UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain,
* `SharedPreferences`, Android DataStore, NSUserDefaults, ...). Либа
* не навязывает формат, но вот минимальный набор, который клиент должен
* сериализовать у себя, чтобы пережить перезапуск:
*
* | Поле | Что это | Где взять |
* |---|---|---|
* | `id` | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id, а произвольная строка клиента — обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется клиентом при первом запуске, сохраняется локально |
* | `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
* | `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
*
* Опционально (для UX):
* | Поле | Зачем |
* |---|---|
* | `engineFactory` | Зависит от платформы (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS). Выбор — обычно compile-time. |
*
* Пример минимального persistence-файла (для UI, который хранит JSON):
*
* ```json
* {
* "clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
* "baseUrl": "https://agent.example.com/agentik",
* "token": "s3cret"
* }
* ```
*
* `clientId` генерируется один раз при первой установке (`UUID.randomUUID().toString()`)
* и больше не меняется — иначе сломается log multiplexing на сервере.
*
* ## Локальный кэш списка бесед
*
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
* Внутри лежит [InMemoryMutableConversationStore], который:
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`.
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
* соответствующий `upsert/delete/rename/touch` к локальной копии.
*
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
* через [Agent] и **не** через `conversationStore` (он read-only).
*
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
* HttpClient + локальный кэш + background-coroutine (идемпотентно).
* После этого `createConversation` / `getConversation` etc. не определены.
*/
fun AgentikAgent(
id: String,
baseUrl: String,
httpClient: HttpClient = defaultAgentikHttpClient(),
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
engineFactory: HttpClientEngineFactory<*>,
token: String? = null,
): Agent {
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])
* живёт в общем коде и плагина `SSEClientContent` не требует.
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
* локальным in-memory кэшем, синхронизированным с удалённым стором
* через outbox-события.
*
* **Платформы:**
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
* `ConversationClient.events`/`AgentClient.events` уже ставят
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
* - **Seed**: при создании делает один snapshot через
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
*
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
* Реализация — в [HttpClientFactory.kt].
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
* (на read-only projection локального [InMemoryMutableConversationStore]).
* Остальные методы [Agent] — delegated в [delegate].
*/
private fun wrapWithLocalConversationCache(
delegate: Agent,
scopeClient: Agent,
): Agent = object : Agent by delegate {
private val localStore: MutableConversationStore = InMemoryMutableConversationStore()
private val cacheScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val syncJob: Job
init {
// Делаем cacheStore read-only view на localStore.
// (Через вложенный класс — см. ниже.)
// Запускаем seed + live-refresh параллельно.
syncJob = cacheScope.launch {
// 1. seed — snapshot всех текущих бесед с сервера
try {
delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE)
.collect { rec -> localStore.upsert(rec) }
} catch (_: Throwable) {
// seed может упасть (offline / 5xx) — не критично,
// live-источник всё равно догонит при первом событии.
}
// 2. live — применяем outbox-события.
// Используем `first()` для knownId после Created — потом отписываемся,
// потому что Created нужно вытянуть полный record через `remote.get(id)`.
// Renamed/Touched меняют локальную копию без round-trip.
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce ->
when (val ev = ce.event) {
is AgentEvent.Created -> {
// Created не несёт title/timestamps — нужно сходить в remote.
val rec = delegate.conversationStore.get(ev.conversationId)
if (rec != null) localStore.upsert(rec)
}
is AgentEvent.Deleted -> localStore.delete(ev.id)
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
}
}
}
}
/**
* Read-only projection локального кэша — клиент через него только
* читает (`get` / `list` / `listFlow`).
*/
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
override fun close() {} // owned by outer close
}
override fun close() {
cacheScope.cancel()
runBlocking { syncJob.join() }
delegate.close()
}
}
@@ -6,18 +6,12 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant
@@ -72,22 +66,6 @@ internal class ConversationClient(
httpClient.post("$convUrl/interrupt")
}
override fun events(after: Instant): Flow<Event> = flow {
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
}
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
httpClient.get("$convUrl/messages") {
parameter("after", after.toString())
@@ -23,4 +23,4 @@ data class ConversationSnapshot(
internal data class RequestCreateConversation(val temp: Boolean)
@Serializable
internal data class RequestRename(val title: String)
internal data class RequestRename(val title: String?)
@@ -1,18 +1,32 @@
package pw.binom.agentik.client
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.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
/**
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
* Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
*
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
* Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
* не нужен — он передаёт engine в [AgentikAgent] и получает готовый
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
* на [pw.binom.agentik.proto.Agent.close].
*
* Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
* (например, дополнительные нестандартные запросы в обход `Agent` API).
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
engine { requestTimeout = 0 }
fun agentikHttpClient(
engineFactory: HttpClientEngineFactory<*>,
token: String? = null,
): HttpClient = HttpClient(engineFactory) {
install(ContentNegotiation) { json(agentikJson) }
if (token != null) {
install(DefaultRequest) {
header(HttpHeaders.Authorization, "Bearer $token")
}
}
}
@@ -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-шным
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
* Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
*
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
@@ -0,0 +1,106 @@
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.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Тесты клиентской части: [agentikHttpClient] с заданным `token` прикладывает
* `Authorization: Bearer <token>` ко всем запросам через плагин `DefaultRequest`,
* без токена — заголовок не отправляется.
*
* Сервер в тесте — локальный ktor-CIO с inline route-scoped Bearer-плагином (один в один
* как боевой [pw.binom.agentik.server.BearerTokenPlugin]). Тестовый `:server` не зависит
* от `:client`, поэтому боевой плагин тут переиспользовать нельзя — пересоздаём его
* минимально, контракт тот же.
*/
class BearerHeaderTest {
private val TestBearer = createRouteScopedPlugin(
name = "TestBearer",
createConfiguration = ::BearerCfg,
) {
val expected = pluginConfig.token
onCall { call ->
if (expected == null) return@onCall
if (call.request.headers[HttpHeaders.Authorization] != "Bearer $expected") {
call.respondText("Unauthorized", ContentType.Text.Plain, HttpStatusCode.Unauthorized)
}
}
}
private class BearerCfg {
var token: String? = null
}
private fun clientWith(token: String?): HttpClient =
agentikHttpClient(engineFactory = CIO, token = token)
private suspend fun startServer(): Pair<EmbeddedServer<*, *>, Int> {
val server = embeddedServer(ServerCIO, port = 0) {
routing {
route("/agentik") {
install(TestBearer) { token = "secret" }
get("/conversations") {
call.respondText("[]")
}
}
}
}.start(wait = false)
val port = server.engine.resolvedConnectors().first().port
return server to port
}
@Test
fun clientWithTokenAttachesBearerHeader() = runBlocking {
val (server, port) = startServer()
try {
val client = clientWith("secret")
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.OK, resp.status)
assertEquals("[]", resp.bodyAsText())
} finally {
server.stop(100, 200)
}
}
@Test
fun clientWithoutTokenGets401(): Unit = runBlocking {
val (server, port) = startServer()
try {
val client = clientWith(null)
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.Unauthorized, resp.status)
} finally {
server.stop(100, 200)
}
}
@Test
fun clientWithWrongTokenGets401(): Unit = runBlocking {
val (server, port) = startServer()
try {
val client = clientWith("wrong")
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.Unauthorized, resp.status)
} finally {
server.stop(100, 200)
}
}
}
@@ -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
@@ -7,7 +7,7 @@ import kotlin.time.Instant
*
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
* Агент не должен с этим типом работать напрямую — он работает с
* [WorkingMemoryEntry] через [WorkingMemoryStore].
* [WorkingMemoryEntry] через [ContextStore].
*/
data class WorkingMemoryRow(
val id: String,
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
*
* Суммаризация / чистка — один атомарный вызов [compact].
*/
interface WorkingMemoryStore : AutoCloseable {
interface ContextStore : AutoCloseable {
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
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.Serializable
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageContext
/**
* Запись в 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 (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json)
api(project(":context-api"))
api(project(":journal-api"))
api(project(":reflection-api"))
// :context-api ссылается на Content / MessageContext из
// :message-log-api (старый canonical). Транзитивно через api,
// но фиксируем явно чтобы тестовый код видел Content без
// обхода через :context-api.
// api(project(":message-log-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,230 @@
package pw.binom.agentik.context.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.WorkingMemoryRow
import pw.binom.agentik.journal.Ids
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
*
* Единственный владелец таблицы `working_memory` в проекте. Используется
* напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close].
* - `KsqliteContextStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
*/
class KsqliteContextStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : ContextStore {
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
// SIGSEGV в StmtHolder.finalize на закрытой connection).
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
ORDER BY ${Schema.COL_ORDER_IDX} ASC
""".trimIndent()
)
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val maxOrderIdxStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
""".trimIndent()
)
private val dropFromIdxStmt: SQLitePreparedStatement = connection.prepare(
"""
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
""".trimIndent()
)
private val insertSummaryStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, NULL, ?, ?, ?)
""".trimIndent()
)
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
val newIdx = maxOrderIdx(conversationId) + 1
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, Ids.new("wm"))
insertStmt.bindText(2, conversationId)
insertStmt.bindLong(3, newIdx)
val srcId = entry.sourceMessageId
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
insertStmt.bindText(5, entryKind(entry))
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
insertStmt.bindLong(7, now.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
val out = mutableListOf<WorkingMemoryRow>()
listStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(
WorkingMemoryRow(
id = rs.getText(0)!!,
conversationId = rs.getText(1)!!,
orderIdx = rs.getLong(2)!!,
sourceMessageId = rs.getText(3),
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
)
)
}
}
out
}
}
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String?,
): Long = withContext(Dispatchers.Default) {
mutex.withLock {
var newMax = 0L
val nowMs = Clock.System.now().toEpochMilliseconds()
val summaryId = Ids.new("wm")
connection.exec("BEGIN")
try {
dropFromIdxStmt.reset()
dropFromIdxStmt.clearBindings()
dropFromIdxStmt.bindText(1, conversationId)
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
dropFromIdxStmt.executeUpdate()
if (!summaryText.isNullOrBlank()) {
val afterDelete = maxOrderIdx(conversationId)
val newIdx = afterDelete + 1
insertSummaryStmt.reset()
insertSummaryStmt.clearBindings()
insertSummaryStmt.bindText(1, summaryId)
insertSummaryStmt.bindText(2, conversationId)
insertSummaryStmt.bindLong(3, newIdx)
insertSummaryStmt.bindText(4, "summary")
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
insertSummaryStmt.bindLong(6, nowMs)
insertSummaryStmt.executeUpdate()
newMax = newIdx
} else {
newMax = maxOrderIdx(conversationId)
}
connection.exec("COMMIT")
} catch (t: Throwable) {
runCatching { connection.exec("ROLLBACK") }
throw t
}
newMax
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
maxOrderIdxStmt.close()
dropFromIdxStmt.close()
insertSummaryStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteContextStore =
KsqliteContextStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
private fun maxOrderIdx(conversationId: String): Long {
maxOrderIdxStmt.reset()
maxOrderIdxStmt.clearBindings()
maxOrderIdxStmt.bindText(1, conversationId)
maxOrderIdxStmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0) ?: 0L
}
return 0L
}
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
is WorkingMemoryEntry.User -> "user"
is WorkingMemoryEntry.Assistant -> "assistant"
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
is WorkingMemoryEntry.Summary -> "summary"
}
}
@@ -0,0 +1,98 @@
package pw.binom.agentik.context.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:context-api`.
*
* Минимум — только то, что относится к `working_memory` (реализация
* [KsqliteContextStore]). Остальные таблицы агента (`conversation`,
* `message`, `reflection`) живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
const val TABLE_WORKING_MEMORY = "working_memory"
// ───── Колонки ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_ORDER_IDX = "order_idx"
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ─────
const val IDX_WM_UNIQUE = "idx_wm_unique"
const val IDX_WM_CONV = "idx_wm_conv"
private val v1Ddl = """
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_ORDER_IDX INTEGER NOT NULL,
$COL_SOURCE_MESSAGE_ID TEXT,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
""".trimIndent()
private val v1IndexesDdl = """
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
""".trimIndent()
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
*/
fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
}
@@ -0,0 +1,99 @@
package pw.binom.agentik.context.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.journal.Content
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
* SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteContextStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteContextStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
store = KsqliteContextStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User =
WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content)))
private fun asstMsg(content: String): WorkingMemoryEntry.Assistant =
WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content)))
@Test
fun testAppendAndListReturnsInOrder() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("first"), t)
store.append("c1", asstMsg("reply"), t)
val list = store.list("c1")
assertEquals(2, list.size)
assertEquals(1L, list[0].orderIdx)
assertEquals(2L, list[1].orderIdx)
}
@Test
fun testListIsolatesConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("c1-msg"), t)
store.append("c2", userMsg("c2-msg"), t)
assertEquals(1, store.list("c1").size)
assertEquals(1, store.list("c2").size)
}
@Test
fun testClearRemovesAllForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
store.append("c1", userMsg("b"), t)
store.clear("c1")
assertEquals(emptyList(), store.list("c1"))
}
@Test
fun testCompactDeletesAndInsertsSummary() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
store.append("c1", userMsg("b"), t)
store.append("c1", userMsg("c"), t)
// dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a).
// Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2.
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary")
assertEquals(2L, newMax)
val remaining = store.list("c1")
assertEquals(2, remaining.size)
assertEquals(1L, remaining[0].orderIdx)
assertEquals(2L, remaining[1].orderIdx)
assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary)
}
@Test
fun testCompactWithoutSummaryKeepsTailBelow() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
// dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1.
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
assertEquals(1L, newMax)
val remaining = store.list("c1")
assertEquals(1, remaining.size)
assertEquals(1L, remaining[0].orderIdx)
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ Main.kt
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
summarization-вставка отложена (нужен дизайн-проработка).
**`MessageStore`** — append-only аудит. На каждый ход дописываются
**`JournalStore`** — append-only аудит. На каждый ход дописываются
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
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` не пишется (модель не должна видеть ошибки прошлых ходов).
### `MessageStore`
### `JournalStore`
```kotlin
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>
```
### `WorkingMemoryStore`
### `ContextStore`
```kotlin
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-вызовом для генерации текста).
### `ConversationStore`
### `MutableConversationStore`
```kotlin
suspend fun upsert(record: ConversationRecord)
+158
View File
@@ -0,0 +1,158 @@
# 01 — Слои модулей (целевое состояние)
Целевая модульная структура agentik. Снизу вверх:
**приложения → runtime → домен → абстракции → платформенные impl**.
![Module Layers](./01-module-layers.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml agentik-module-layers
skinparam componentStyle rectangle
skinparam ranksep 60
skinparam nodesep 30
skinparam packageStyle rectangle
title agentik — слои модулей (целевое состояние)
' --- Applications: entry points (thin wrappers) ---
package "Applications\n(entry points, тонкие)" {
[Standalone\nHTTP+AG-UI+A2A] as Standalone
[AgentikCli\nREPL] as Cli
[AgentikAndroid\nCompose UI] as Android
}
' --- Agent runtime ---
package "Agent Runtime\n(композиция, lifecycle)" {
[AgentCore\nBaseAgent] as AgentCore
[AgentBuilder\nDSL] as Builder
}
' --- Background work ---
package "Background Work\n(event-driven triggers)" {
[BackgroundEvents\nbus + events] as Ev
[BackgroundScheduler\npolicy] as Sched
}
' --- Domain logic (generic, переиспользуется) ---
package "Domain Logic\n(generic tools)" {
[LlmTools\nReflector/Reviewer/Miner] as LlmT
[McpBridge\nMCP-SDK → LiteTool] as Mcp
[Skills\nparse + store] as Skills
}
' --- Storage abstractions + impls ---
package "Storage\n(abstractions)" as StoragePkg {
[StorageCore\ninterfaces] as StorageCore
}
package "Storage\n(JVM impls)" {
[StorageSqlite\nJDBC] as StorageSql
[StorageInmemory\ntests] as StorageInmem
}
package "Storage\n(Android impl)" {
[StorageSqliteAndroid\nRoom/sqlite] as StorageSqlA
}
' --- Memory backends ---
package "Memory\n(abstractions)" {
[MemoryApi\nMemorySystem/MemoryTools] as MemApi
}
package "Memory\n(impls)" {
[MemoryMd\nHermes §-files] as MemMd
[MemoryVector\nJVector+JVM] as MemVec
[MemoryVectorAndroid\nONNX+ANN] as MemVecA
}
' --- LLM backends ---
package "LLM\n(abstractions)" {
[LitertApi\nLiteLlm контракт] as Litert
}
package "LLM\n(impls)" {
[LitertOpenai\nHTTP] as LitertO
[LitertGoogle\nLiteRT JVM] as LitertG
[LitertAndroid\nLiteRT Android] as LitertA
}
' --- Inter-app protocol ---
package "Inter-app" {
[Proto\nAgent/Conversation] as Proto
[A2AServer] as A2A
}
' --- Зависимости (приложения → runtime → домен → абстракции → платформенные импл) ---
Standalone ..> Builder
Cli ..> Builder
Android ..> Builder
Builder ..> AgentCore
AgentCore ..> Proto
AgentCore ..> StorageCore
AgentCore ..> MemApi
AgentCore ..> Litert
AgentCore ..> Mcp
AgentCore ..> Skills
Sched ..> Ev
AgentCore ..> Sched
AgentCore ..> Ev
Mcp ..> Litert
LlmT ..> Litert
MemMd ..> MemApi
MemVec ..> MemApi
MemVecA ..> MemApi
StorageSql ..> StorageCore
StorageInmem ..> StorageCore
StorageSqlA ..> StorageCore
LitertO ..> Litert
LitertG ..> Litert
LitertA ..> Litert
Standalone ..> A2A
Standalone ..> LitertO
Standalone ..> LitertG
Standalone ..> StorageSql
Standalone ..> MemMd
Standalone ..> MemVec
Standalone ..> Mcp
Android ..> LitertA
Android ..> StorageSqlA
Android ..> MemMd
Android ..> MemVecA
@enduml
```
## Что показывает
- **Applications** — три точки входа: web-сервер, CLI REPL, Android-приложение. Каждое тонкое, не содержит бизнес-логики.
- **Agent Runtime** — `BaseAgent` + `AgentBuilder` DSL. Вся композиция и lifecycle.
- **Background Work** — `BackgroundEvents` (event-bus) + `BackgroundScheduler` (policy подписки). Event-driven, не interval-polling.
- **Domain Logic** — generic переиспользуемые модули (`:llm-tools`, `:mcp-bridge`, `:skills`).
- **Storage / Memory / LLM** — каждая с абстракцией и одним или несколькими impl (JVM-only или Android-only).
- **Inter-app** — `:proto` контракты + `:a2a-server` для межагентного общения.
## Текущее состояние vs целевое
✅ Уже сделано (в этом цикле правок):
- `:llm-tools` extracted
- `:mcp-bridge` extracted
- `BackgroundScheduler` стал event-driven
- `ConversationLoop` стал отдельным компонентом (typealias `ChatConversation`)
⏳ Не сделано:
- `:agent-core` (выделить `BaseAgent` + builder в отдельный KMP-модуль)
- `:background-events` (выделить events + scheduler — пока в `:standalone`)
- `:storage-sqlite-android`
- `:memory-vector-android`
- `:litert-android`
- `:agentik-android` (само приложение)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

+127
View File
@@ -0,0 +1,127 @@
# 02 — Agent Builder: композиция (целевое API)
Как `AgentBuilder` собирает `BaseAgent` из компонентов. **Memory backend сам объявляет свои tools** — builder их авто-мержит. BackgroundScheduler подписан на события, не interval-poll.
![Agent Composition](./02-agent-composition.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml agent-composition
skinparam componentStyle rectangle
title Agent Builder — композиция (целевое API)
' --- Builder ---
rectangle "AgentBuilder" as Builder {
rectangle "llm: LiteLlm (обязательно)" as Llm
rectangle "storage: StorageBundle (обязательно)" as Storage
rectangle "memory: MemorySystem (обязательно)" as Mem
rectangle "soul: SoulProvider (default NoopSoul)" as Soul
rectangle "tools: List<NamedTool> (авто-сборка из backends)" as Tools
rectangle "background: BackgroundConfig (default EmptyBg)" as Bg
rectangle "toolset: List<ToolsetContribution> (default empty)" as Ts
}
' --- Backends with their tool side-effects ---
rectangle "MemoryMd" as MdMem {
interface "MemorySystem" as MemSys
interface "List<NamedTool>" as MdTools
note right
MemoryMd.exposesTools() →
memory_save / memory_read /
memory_list / memory_delete
end note
}
rectangle "McpRegistry" as McpReg {
interface "List<NamedTool>" as McpTools
note right
McpRegistry.namedTools →
server__tool1, server__tool2,
...
end note
}
rectangle "BackgroundEvents" as Events {
interface "MutableSharedFlow<CompactionEvent|ToolCallEvent|LifecycleEvent>" as Flow
note right
Эмитится из:
- CompactionCoordinator
- ToolDispatcher
- ConversationLoop.close()
end note
}
rectangle "BackgroundScheduler" as Sched {
interface "policy: trigger + debounce" as Policy
note right
Подписан на Events.
НИКАКОГО interval-polling.
end note
}
' --- Получаемый Agent ---
rectangle "BaseAgent\n(impl: ConversationLoop)" as Agent {
rectangle "send / interrupt / events" as API
rectangle "BackgroundScheduler\nподписка" as Sub
}
' --- Стрелки зависимостей ---
Builder --> Llm
Builder --> Storage
Builder --> Mem
Builder --> Soul
Builder --> Tools
Builder --> Bg
Builder --> Ts
MdMem --> Mem : implements
MdMem --> MdTools : exposes
McpReg --> McpTools : exposes
Bg --> Events : subscribes-to
Bg --> Sched : holds
Tools <-- MdTools : auto-merge
Tools <-- McpTools : auto-merge
Builder --> Agent : build()
Agent --> API
Agent --> Sub
@enduml
```
## Целевой Kotlin DSL
```kotlin
val agent = agentBuilder {
// Обязательные
llm(OpenAiLlm.fromEnv()) // или LitertAndroid.onDevice(context)
storage(SqliteStorage(path)) // или SqliteStorage.android(context)
memory(MemoryMd(root = "~/memory")) // или MemoryVector(embedding = HttpEmbedding(...))
// Опциональные
soul(FileSoul("~/SOUL.md")) // или HttpSoul(url), NoopSoul()
background {
// triggers: OnClosing (reflection+mining), OnCompaction(minTurns=10, mining=true)
// event-driven, не interval
}
tools {
// memoryMd.exposesTools() + mcpRegistry.namedTools авто-подцепляются
+FileReadTool(root = "/data")
}
toolset {
+MemoryToolsToolset(memoryMd)
}
}.build()
```
## Ключевые решения
- **`MemoryBackend.exposesTools()`** — backend декларирует свои tools. Не «подставить любой backend», а «backend сообщает что он умеет». Это убирает coupling «какие tools совместимы с какими backends».
- **Builder требует только `llm + storage + memory`** как обязательные. Всё остальное — опционально с разумными default'ами (`NoopSoul`, `EmptyBackground`, `empty toolset`).
- **`BaseAgent`** — реализация `ConversationLoop` через builder. Конструктор принимает все нужные компоненты. **Один и тот же `BaseAgent` в `:standalone`, `:agentik-cli`, `:agentik-android`** — отличается только wiring через builder.
- **BackgroundScheduler подписан на `BackgroundEvents`** — это даёт event-driven по умолчанию. `OnEvery(n)` interval-режим — опциональный fallback (не default).
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

+129
View File
@@ -0,0 +1,129 @@
# 03 — Multi-user chat с mention-detection
Точка 1 из планов. Один `BaseAgent` обслуживает N пользователей. Отвечает только когда addressed (mention или admin-команда).
![Multi-user chat](./03-multi-user-chat.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml multi-user-chat
skinparam componentStyle rectangle
skinparam participantPadding 15
skinparam boxPadding 10
title Multi-user chat с mention-detection (точка 1 из планов)
' --- Участники ---
actor "User A" as UA
actor "User B" as UB
actor "User C\n(админ)" as UC
participant "Telegram /\nSlack /\nMatrix" as Channel
participant "AgentRuntime\n(BaseAgent)" as Runtime
participant "MentionDetector" as Detector
participant "SoulProvider" as Soul
participant "MemorySystem\n(MdMemory)" as Memory
participant "LlmBackend\n(LiteLlm)" as Llm
' --- Сценарий ---
UA -> Channel : "@bot, что нового?"
UB -> Channel : "люблю котов"
UC -> Channel : "/bot status"
Channel -> Runtime : событие чата
' --- Внутри Runtime ---
Runtime -> Detector : isMentioned(message, botName)
note right of Detector
variants:
- SimpleMentionDetector (regex: @bot)
- LlmMentionDetector (mini-classifier)
- AdminCommandDetector (/command)
end note
Detector --> Runtime : MatchResult{isMentioned, isCommand}
alt isMentioned или isCommand
Runtime -> Soul : read()
Runtime -> Memory : prefetch(query, topK)
Runtime -> Llm : send(system + history + memory + user)
Llm --> Runtime : response + tool_calls
Runtime -> Memory : save(decision)
Runtime --> Channel : ответ в нужный канал/thread
else NOT mentioned и NOT command
Runtime -> Runtime : drop (если не админ)
note right
Не отвечаем, но возможно:
- запоминаем факт (memory-only update)
- summary на long conversation
end note
end
@enduml
```
## Ключевые модули (что нужно будет добавить)
### `MentionDetector` interface
```kotlin
interface MentionDetector {
data class Result(
val isMentioned: Boolean,
val isAdminCommand: Boolean,
val isPrivateMessage: Boolean, // DM — всегда отвечаем
)
fun detect(message: ChatMessage, botName: String): Result
}
```
Имплементации:
- `SimpleMentionDetector` — regex `@bot`, `/command` (дешёво, latency ~0)
- `LlmMentionDetector` — маленькая классификация через тот же LLM (точнее, но +1 LLM-вызов на каждое сообщение)
- `HybridMentionDetector` — fast regex → fallback на LLM только если ambiguous
### `ChatAdapter` interface
```kotlin
interface ChatAdapter {
val channel: String // "telegram" / "slack" / "matrix"
suspend fun listen(onMessage: (ChatMessage) -> Unit): Job
suspend fun reply(messageId: String, text: String, threadId: String? = null)
suspend fun isAdmin(userId: String): Boolean
}
```
Имплементации per platform. Каждая адаптирует формат platform → `ChatMessage`.
### Конфигурация builder'а
```kotlin
agentBuilder {
llm(...)
storage(...)
memory(...)
soul(...)
background { ... }
chat {
mentionDetector = HybridMentionDetector(regex = "@bot|@Agent", llmClassifier = false)
chatAdapter = TelegramChatAdapter(token = "...")
// На каждое сообщение:
// 1. mentionDetector.detect()
// 2. если isMentioned || isAdminCommand || isPrivate → process
// 3. иначе — опционально memory-only save (тихий режим)
}
}
```
## Что это даёт
- Один `BaseAgent` обслуживает чат целиком (один LLM, одна память — общий контекст команды)
- `@bot` — explicit invocation, не «agent отвечает на всё подряд»
- `/bot status` / `/bot clear-memory` — admin-команды (отдельный канал, без LLM)
- DM — всегда отвечает (это личное обращение)
- В групповом чате без mention — agent может **молча учить** (memory update без ответа). Полезно для «запомнил что Вася любит котов».
## Текущее состояние vs целевое
⏳ Ничего из этого нет. Сейчас `:standalone` — это HTTP API, к которому подключаются clients. Для multi-user chat нужен новый `:chat-adapter-telegram` (или -slack / -matrix) модуль + `MentionDetector` interface.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

+119
View File
@@ -0,0 +1,119 @@
# 04 — Sub-agents + A2A между агентами
Точки 2 и 3 из планов. Orchestrator-агент spawn'ит sub-агентов с изолированным контекстом. Независимые агенты общаются через A2A.
![Sub-agents + A2A](./04-sub-agents.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml sub-agents-and-a2a
skinparam componentStyle rectangle
title Sub-agents + A2A между агентами (точка 2+3 из планов)
' --- Orchestrator ---
rectangle "OrchestratorAgent\n(BaseAgent + tools)" as Orch {
rectangle "ConversationLoop\n(main user)" as MainConv
}
' --- Sub-agent spawn ---
rectangle "subAgent(\n task: String,\n config: AgentConfig\n): Flow<SubAgentEvent>" as SpawnAPI
note right of SpawnAPI
Spawn API — НЕ отдельный модуль,
а convenience поверх BaseAgent:
val sub = agent.spawnChild(config) {
systemPrompt = "..."
tools = [ReadTool, WriteTool]
memory = EmptyMemory // изолированно
}
sub.events.collect { ... }
end note
' --- Дочерний агент (изолированный контекст) ---
rectangle "SubAgent\n(изолированный scope)" as Sub {
rectangle "ConversationLoop\n(child)" as SubConv
rectangle "backgroundScope\n(lifecycle scoped)" as SubBg
}
' --- A2A между независимыми агентами ---
rectangle "Agent A\n(BaseAgent)" as AgentA
rectangle "Agent B\n(BaseAgent)" as AgentB
rectangle "A2A Server\n(:a2a-server)" as A2ASrv
AgentA -> A2ASrv : POST /\n(application/json)
A2ASrv -> AgentB : dispatch(message)
AgentB --> A2ASrv : response
A2ASrv --> AgentA : SSE / JSON-RPC
' --- Стрелки ---
Orch -> SpawnAPI : calls
SpawnAPI -> Sub : creates with custom config
Sub -> SubBg : has its own
Orch -> Orch : main flow continues
Sub --> Orch : Flow<SubAgentEvent> emits\n(Started / ToolCalled / ToolResult /\nAssistantMessage / Done / Failed)
Orch -> A2ASrv : can also delegate to remote agent
@enduml
```
## Sub-agents API
```kotlin
sealed interface SubAgentEvent {
data class Started(val taskId: String) : SubAgentEvent
data class AssistantMessage(val text: String) : SubAgentEvent
data class ToolCalled(val toolName: String, val args: JsonObject) : SubAgentEvent
data class ToolResult(val toolName: String, val result: String) : SubAgentEvent
data class Done(val taskId: String, val finalResult: String) : SubAgentEvent
data class Failed(val taskId: String, val error: String) : SubAgentEvent
}
interface BaseAgent {
// ... existing methods ...
/**
* Spawn дочерний агент с изолированным контекстом (memory, system prompt,
* tools). Возвращает Flow событий жизненного цикла + результата.
* Cancellation родителя НЕ отменяет sub-agent — sub-agent живёт до Done/Failed.
*/
fun spawnChild(config: SubAgentConfig): Flow<SubAgentEvent>
}
data class SubAgentConfig(
val systemPrompt: String,
val tools: List<NamedTool> = emptyList(),
val memory: MemorySystem = EmptyMemory(),
val model: LiteLlm? = null, // если null — делит LLM родителя
val maxTurns: Int = 10,
val timeoutMs: Long = 60_000,
)
```
## Зачем изолированный scope
Sub-agent получает **свою копию контекста**, не делит memory с родителем. Это критично:
- `research_subagent` — должен исследовать тему, не отвечать на основные сообщения пользователя
- `summarize_subagent` — суммаризировать документ, не трогать основной диалог
- `code_review_subagent` — ревьюить PR, не видеть разговор
Если нужно расшарить контекст — это explicit через `sharedMemory: SharedMemoryHandle` параметр, не default.
## A2A между независимыми агентами
Уже есть `:a2a-server` модуль (см. `standalone/build.gradle.kts` — `implementation(libs.a2a.server)`). Использовался для AG-UI/A2A протокола в `:standalone`. Можно переиспользовать для межагентного общения.
Сценарий: orchestrator-agent не может сам решить задачу → делегирует remote-агенту через A2A → получает response → продолжает. Это уже работающая инфраструктура.
## Текущее состояние vs целевое
✅ Уже есть:
- `:a2a-server` подключён
- `BaseAgent.spawnChild` — **не существует**, но `ConversationLoop` уже умеет создавать изолированный scope через свой `agentScope` — нужна только обёртка
⏳ Не сделано:
- `SubAgentConfig` + `Flow<SubAgentEvent>` API
- `EmptyMemory` (null-object для изолированного scope)
- Lifecycle management (parent dies → child должен complete or be cancelled?)
- Сериализация sub-agent state для отладки (event log)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

+171
View File
@@ -0,0 +1,171 @@
# 05 — Android Agent Stack
Что меняется vs `:standalone`. Цель: `BaseAgent` тот же самый, но platform-impl разные (Storage, LLM, Vector Memory, MCP).
![Android Agent Stack](./05-android-stack.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml android-agent-stack
skinparam componentStyle rectangle
title Android Agent Stack — что меняется vs Standalone
' --- Android side ---
package "Android Application" {
[MainActivity\n(Compose)] as Activity
[AndroidAgentRunner\n(workmanager / service)] as Runner
[AndroidAgentBuilder] as AndroidBuilder
}
package "Android-specific impls" {
[StorageSqliteAndroid\n(Room/sqlite)] as StorageA
[MemoryVectorAndroid\n(ONNX runtime + ANN)] as MemVecA
[LitertAndroid\n(NNAPI delegate)] as LitertA
[SoulFileAndroid\n(context.filesDir)] as SoulA
[McpRegistry\nstdio: ProcessBuilder] as McpA
}
' --- Shared (KMP) ---
package "Agent Runtime (shared)" {
[AgentCore\nBaseAgent] as AgentCore
[AgentBuilder] as Builder
}
package "Domain (shared)" {
[LlmTools\ncommonMain] as LlmT
[BackgroundEvents\ncommonMain] as Ev
[McpBridge\njvmMain] as McpB
[Skills\ncommonMain] as Skills
}
package "Memory (shared impl)" {
[MemoryMd\n(commonMain)] as MemMd
[MemoryApi\ninterfaces] as MemApi
}
' --- Зависимости ---
Activity --> Runner
Runner --> AndroidBuilder
AndroidBuilder --> AgentCore
AndroidBuilder --> StorageA
AndroidBuilder --> MemVecA
AndroidBuilder --> LitertA
AndroidBuilder --> SoulA
AndroidBuilder --> McpA
AgentCore --> LlmT
AgentCore --> Ev
AgentCore --> McpB
AgentCore --> Skills
AgentCore --> MemMd
' --- Главные отличия от Standalone ---
note right of LitertA
On-device inference.
LiteRT с NNAPI delegate →
работает на CPU/GPU/NPU
прямо на устройстве, без сети.
vs Standalone: HTTP-only
(OpenAI-compatible).
end note
note right of StorageA
android.database.sqlite
через Room или сырой API.
vs Standalone: JDBC +
Sqlite-JDBC driver
(только JVM).
end note
note right of MemVecA
JVector JVM-only. На Android
нужна альтернатива —
ONNX Runtime + какой-нибудь
ANN (Annoy/HNSW).
Или пока без vector memory,
только MemoryMd.
end note
note right of McpA
MCP через ProcessBuilder
на Android работает, но
subprocess lifecycle
сложнее (foreground service
нужен для долгого subprocess).
end note
@enduml
```
## Что общего с `:standalone`
**`BaseAgent`, `BackgroundScheduler`, `LlmTools`, `McpBridge`, `Skills`, `MemoryMd` — всё KMP (commonMain).** Android-agent = `:standalone` с другим wiring'ом. Не нужно переписывать agent logic.
## Что другое
| Компонент | `:standalone` (JVM) | `:agentik-android` (Android) | Сложность |
|---|---|---|---|
| Storage | `:storage-sqlite` (JDBC + Sqlite-JDBC) | `:storage-sqlite-android` (Room или raw) | Низкая — тот же `StorageBundle` interface |
| LLM | `:litert-openai` (HTTP), `:litert-google` (LiteRT JVM) | `:litert-android` (LiteRT Android, NNAPI delegate) | Средняя — нужен новый модуль |
| Vector memory | `:memory-vector` (JVector) | `:memory-vector-android` (ONNX Runtime + HNSW/Annoy) | Высокая — JVector JVM-only, нужна альтернатива |
| SOUL provider | `FileSoulProvider` (path) | `SoulFileAndroid` (`context.filesDir`) | Низкая |
| MCP | `McpRegistry` (ProcessBuilder, stdio subprocess) | Тот же `McpRegistry`, но subprocess в foreground service | Средняя — нужен Android service |
| Embedding | `HttpEmbeddingClient` (HTTP) | Тот же ИЛИ on-device (ONNX) | Средняя |
## Минимальный Android agent (v1)
Если не нужны все фичи сразу — минимум:
```kotlin
val agent = androidAgentBuilder(context) {
llm(LitertAndroid.onDevice(context, modelPath = "/data/local/tmp/model.litertlm"))
storage(SqliteStorage.android(context, "agent.db"))
memory(MemoryMd.root(context.filesDir.resolve("memory")))
soul(FileSoul(context.filesDir.resolve("SOUL.md")))
background {
// OnClosing + OnCompaction работают так же как на JVM
}
}
```
Без MCP, без vector memory (только MemoryMd на файлах), только on-device LLM. Достаточно для off-line агента.
## Foreground service для MCP
Если нужны MCP-серверы (например, локальный file-system MCP) — subprocess нужен foreground service чтобы Android не убил его при выключении экрана. Это добавляет сложности:
```kotlin
class McpForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, notification)
val proc = ProcessBuilder(command, args).start()
// ... route stdio to McpLiteToolAdapter ...
return START_STICKY
}
}
```
Пока можно без этого (только если MCP нужен на Android).
## Текущее состояние vs целевое
✅ KMP-ready:
- `:llm-tools` (commonMain, платформо-агностик)
- `:mcp-bridge` (jvmMain — Android-вариант через `:mcp-bridge-android`)
- `:skills` (commonMain)
- `:memory-md` (commonMain)
- `:proto` (commonMain)
⏳ Не существует:
- `:storage-sqlite-android`
- `:memory-vector-android`
- `:litert-android`
- `:agentik-android` (само приложение)
- `:agent-core` (выделить BaseAgent + builder)
- `:background-events` (выделить events + scheduler)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

+64
View File
@@ -0,0 +1,64 @@
# agentik — диаграммы архитектуры
PlantUML-схемы для обсуждения будущей структуры (Android agent, multi-user chat, sub-agents, A2A). Это **целевое состояние**, не текущее.
## Файлы
Каждый `.md` содержит:
- Краткое описание (что показывает)
- **Пред-рендеренный SVG** (`![Diagram](file.svg)`) — гарантированно показывается **везде**
- PlantUML source в ` ```plantuml ` блоке — для редактирования (требует Graphviz `dot` для рендеринга)
- Дополнительный markdown-текст (что нужно сделать, текущее vs целевое)
| Файл | Что показывает |
|---|---|
| [01-module-layers.md](./01-module-layers.md) | Целевая модульная структура (приложения → runtime → домен → абстракции → платформенные impl). Что в каком слое и кто от кого зависит. |
| [02-agent-composition.md](./02-agent-composition.md) | Как `AgentBuilder` собирает `BaseAgent` из компонентов. Memory backend сам объявляет свои tools. BackgroundScheduler подписан на события (НЕ interval-poll). |
| [03-multi-user-chat.md](./03-multi-user-chat.md) | Сценарий: чат с N пользователями, mention-detection, админ-команды, agent отвечает только когда addressed. |
| [04-sub-agents.md](./04-sub-agents.md) | Orchestrator spawn'ит sub-agent с изолированным контекстом, получает `Flow<SubAgentEvent>`. A2A между независимыми агентами через `:a2a-server`. |
| [05-android-stack.md](./05-android-stack.md) | Что меняется на Android: on-device LLM (NNAPI), Room/sqlite, ONNX-based vector memory, foreground-service для MCP subprocess. |
## Почему SVG + PlantUML source
PlantUML требует Java + (для component/class/deployment диаграмм) Graphviz `dot`. Если `dot` не установлен — рендерер падает с ошибкой "Executable dot does not exist".
Решение: **пре-рендерим в SVG один раз** и вставляем как `<img>`. Диаграмма гарантированно показывается в любом markdown-viewer (GitHub, IntelliJ, VSCode, GitLab) без зависимостей. PlantUML source в code block остаётся для редактирования.
## Как редактировать диаграмму
1. Меняешь PlantUML-source в ` ```plantuml ` блоке `.md` файла.
2. Ре-рендеришь SVG:
```bash
mkdir -p /tmp/plantuml-work && chmod 777 /tmp/plantuml-work
cp docs/diagrams/*.md /tmp/plantuml-work/
docker run --rm -v /tmp/plantuml-work:/work plantuml/plantuml -tsvg /work/*.md
cp /tmp/plantuml-work/*.svg docs/diagrams/
```
3. Проверяешь что SVG обновился:
```bash
ls -la docs/diagrams/*.svg
```
4. Коммитишь оба файла: `.md` (source) и `.svg` (rendered).
Требует Docker (или локального PlantUML+Graphviz). `apt install graphviz` для Arch/Manjaro.
## Контекст
Текущий код движется в эту сторону:
- `:llm-tools` extracted ✅
- `:mcp-bridge` extracted ✅
- `BackgroundScheduler` стал event-driven ✅
- `ConversationLoop` стал отдельным компонентом ✅
Не сделано (см. детали в каждом .md):
- `:agent-core` (выделить `BaseAgent` + builder)
- `:background-events` (выделить events + scheduler)
- `:storage-sqlite-android`, `:memory-vector-android`, `:litert-android`
- `:agentik-android` (само приложение)
- `MentionDetector` interface + adapters для multi-user chat
- `BaseAgent.spawnChild` + `Flow<SubAgentEvent>`
Подробнее:
- `STANDALONE-REVIEW.md` — что плохо в текущем коде
- `MEMORY-DESIGN.md` — детали memory архитектуры
- `STANDALONE.md` — текущий standalone
+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)
}
}
}
+16 -5
View File
@@ -6,11 +6,11 @@ kotlinx-io = "0.8.0"
ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0"
litert = "8"
litert = "13"
sqldelight = "2.3.2"
shadow = "8.3.5"
jvector = "3.0.6"
text-embedding-kmp = "3.0.0-SNAPSHOT"
text-embedding-kmp = "5"
kotlin-logging = "3.0.5"
logback = "1.5.18"
mosaic = "0.18.0"
@@ -40,6 +40,9 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
# liteTool(liteToolRaw) DSL: типизированные LiteTool через @Serializable args.
# https://git.binom.pw/subochev/litert-kmp/src/branch/main/litert-tools-kotlinx-serialization
litert-tools-kotlinx-serialization = { module = "pw.binom.litert:litert-tools-kotlinx-serialization", version.ref = "litert" }
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
@@ -97,10 +100,18 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
# (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
# Версия 5 — первый релиз с реальными нативными klib-вариантами в caffeine
# (v4 имел только jvm+android, что ломало native-resolve в :memory-md-vector).
# Используется из :memory-md-vector и :memory-vector напрямую через
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
# нужный variant под target).
# `siglip` — JVM+Android only (onnx-runtime), подключается в jvmMain.
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "text-embedding-kmp" }
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" }
# KMP-артефакт (он же `kotlin-logging-jvm` существует отдельно как JVM-only build).
kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
@@ -1,13 +1,10 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
// Зеркалит набор :storage-core — in-memory импл, чтобы тесты и embedded
// (Android) запуски не зависели от SQLite/JDBC. Совпадает по семантике
// с :storage-sqlite (тред-безопасность через Mutex, AutoCloseable).
jvm()
macosX64()
macosArm64()
@@ -20,7 +17,9 @@ kotlin {
sourceSets {
commonMain.dependencies {
api(project(":storage-core"))
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
@@ -1,17 +1,12 @@
package pw.binom.agentik.storage
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Часть контента сообщения на уровне хранилища.
*
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
* идентичны, но даёт возможность заменить transport-протокол без миграции
* таблиц.
*
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
* Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
* `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
* в `Mapping.kt` storage impl'ов.
*/
@Serializable
sealed interface Content {
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage
package pw.binom.agentik.journal
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
@@ -11,5 +11,4 @@ import kotlin.uuid.Uuid
*/
object Ids {
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
/**
* CRUD по таблице `conversation`.
*/
interface ConversationStore : AutoCloseable {
interface MutableConversationStore : ConversationStore {
/** Создать или обновить snapshot диалога. */
suspend fun upsert(record: ConversationRecord)
/** Диалог по id, или `null`. */
suspend fun get(id: String): ConversationRecord?
/** Удалить диалог (вместе с его сообщениями и working memory). */
suspend fun delete(id: String): Boolean
/** Список диалогов, отсортированный по `updatedAt` DESC. */
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
suspend fun rename(id: String, title: String?): Instant?
@@ -0,0 +1,25 @@
package pw.binom.agentik.journal
/**
* Mutable вариант [JournalStore] — добавляет producer-операцию [append].
*
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
* ConversationLoop, ToolDispatcher, sub-agents, A2A-bridge).
* Consumer'ы (DebugRoutes, admin dashboards, parent agents) должны
* принимать **read-only** [JournalStore] — тогда невозможно случайно
* записать в audit log из observer'а.
*
* **Append семантика**: см. KDoc [MessageStore.append][JournalStore] —
* на этом интерфейсе (не дублируем).
*/
interface MutableJournalStore : JournalStore {
suspend fun append(record: MessageRecord)
/**
* Удалить все сообщения диалога [conversationId]. Используется
* владельцем lifecycle диалога при его удалении (каскад из
* ChatAgent.deleteConversation). Append-only природа audit log'а
* не нарушается — это bulk-clear, а не редактирование.
*/
suspend fun clear(conversationId: String)
}
@@ -0,0 +1,47 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
private val bodyJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Serializable
data class MessageBodyPayload(
val content: List<Content>,
@SerialName("context")
val context: MessageContext? = null,
val tokens: TurnTokens? = null,
)
fun encodeBodyPayload(
content: List<Content>,
context: MessageContext? = null,
tokens: TurnTokens? = null,
): String = bodyJson.encodeToString(
MessageBodyPayload.serializer(),
MessageBodyPayload(content = content, context = context, tokens = tokens),
)
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
data class BodyDecoded(
val content: List<Content>,
val context: MessageContext?,
val tokens: TurnTokens? = null,
)
private fun readPayload(json: String): BodyDecoded {
return try {
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
BodyDecoded(p.content, p.context, p.tokens)
} catch (e: kotlinx.serialization.SerializationException) {
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
BodyDecoded(arr, null, null)
}
}
@@ -0,0 +1,18 @@
package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
/**
* Token usage одного assistant turn'а.
*/
@Serializable
data class TurnTokens(
val input: Int,
val output: Int,
) {
val total: Int get() = input + output
init {
require(input >= 0) { "input tokens must be non-negative, got $input" }
require(output >= 0) { "output tokens must be non-negative, got $output" }
}
}
+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()
}
/** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.removeAll { it.conversationId == conversationId }
}
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
suspend fun size(): Int = mutex.withLock { records.size }
override fun close() {
// no-op: lifecycle HttpClient'а — снаружи.
}
}
@@ -1,24 +1,35 @@
package pw.binom.agentik.storage.inmemory
package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore
import kotlin.time.Clock
import pw.binom.agentik.storage.ConversationRecord
import pw.binom.agentik.storage.ConversationStore
import kotlin.time.Instant
/**
* Thread-safe Map-импл [ConversationStore].
* Thread-safe Map-импл [MutableConversationStore] для клиентских
* in-process кэшей (и тестов/dev-режима).
*
* Использует `Mutex` для атомарности read-modify-write операций
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления
* (lost-update race), что в SQLite невозможно из-за driver-level locking.
*
* **Сортировка**: `list()` сортирует по `updatedAt DESC`.
*
* **Типичный кэш-паттерн в клиенте** (см. `client/README.md`):
* ```
* val local = InMemoryMutableConversationStore()
* // seed: remote.listFlow → local.upsert
* // live-refresh: outbox.agentEvents → local.upsert/delete/rename/touch
* // UI: local.list(0, PAGE_SIZE)
* ```
*/
class InMemoryConversationStore(
class InMemoryMutableConversationStore(
private val clock: Clock = Clock.System,
) : ConversationStore {
) : MutableConversationStore {
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf()
private val byId = mutableMapOf<String, ConversationRecord>()
private val mutex = Mutex()
override suspend fun upsert(record: ConversationRecord) {
@@ -0,0 +1,80 @@
package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class InMemoryJournalStoreTest {
private fun userMsg(id: String, convId: String, text: String, at: Instant) =
MessageRecord.UserMessage(
id = id,
conversationId = convId,
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
createdAt = at,
)
@Test
fun `append then list returns records sorted by createdAt ASC`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "first", t0))
store.append(userMsg("m2", "c1", "second", t0 + 1.seconds))
store.append(userMsg("m3", "c1", "third", t0 + 2.seconds))
val all = store.list("c1", Instant.DISTANT_PAST, 0, 100)
assertEquals(3, all.size)
assertEquals(listOf("m1", "m2", "m3"), all.map { it.id })
}
@Test
fun `list filters by conversationId`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c2", "b", t0 + 1.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 2.seconds))
assertEquals(2, store.list("c1", Instant.DISTANT_PAST, 0, 100).size)
assertEquals(1, store.list("c2", Instant.DISTANT_PAST, 0, 100).size)
}
@Test
fun `list filters by after cursor`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
val afterT0 = store.list("c1", t0, 0, 100)
assertEquals(listOf("m2", "m3"), afterT0.map { it.id })
}
@Test
fun `list applies offset and limit`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
repeat(10) { i -> store.append(userMsg("m$i", "c1", "x", t0 + i.seconds)) }
val page = store.list("c1", Instant.DISTANT_PAST, offset = 3, limit = 4)
assertEquals(listOf("m3", "m4", "m5", "m6"), page.map { it.id })
}
@Test
fun `clear empties a conversation only`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "x", t0))
store.append(userMsg("m2", "c2", "y", t0))
assertEquals(2, store.size())
store.clear("c1")
assertEquals(1, store.size())
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
}
}
@@ -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.assertEquals
import kotlin.test.assertNotNull
@@ -9,11 +9,11 @@ import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.test.runTest
class InMemoryConversationStoreTest {
class InMemoryMutableConversationStoreTest {
@Test
fun `upsert and get roundtrip preserves all fields`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
val rec = ConversationRecord(
id = "c1",
title = "test",
@@ -28,13 +28,13 @@ class InMemoryConversationStoreTest {
@Test
fun `get returns null for missing id`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
assertNull(store.get("nope"))
}
@Test
fun `delete removes the record and returns true`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
store.upsert(
ConversationRecord(
"c1", null, false,
@@ -50,7 +50,7 @@ class InMemoryConversationStoreTest {
@Test
fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0))
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
@@ -69,7 +69,7 @@ class InMemoryConversationStoreTest {
@Test
fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0))
@@ -84,7 +84,7 @@ class InMemoryConversationStoreTest {
@Test
fun `rename with null title clears it`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", "old", false, t0, t0))
store.rename("c1", null)
@@ -93,13 +93,13 @@ class InMemoryConversationStoreTest {
@Test
fun `rename returns null for missing conversation`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
assertNull(store.rename("nope", "x"))
}
@Test
fun `touch bumps updatedAt without changing other fields`() = runTest {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
@@ -112,7 +112,7 @@ class InMemoryConversationStoreTest {
@Test
fun `close is idempotent and does nothing`() {
val store = InMemoryConversationStore()
val store = InMemoryMutableConversationStore()
store.close()
store.close() // должно быть no-op
}
+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 (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json)
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,171 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
*
* Единственный класс для message-таблицы. Используется напрямую через
* `:journal-ksqlite` зависимость; bundle'ом собирает
* `pw.binom.agentik.standalone.persistence.SqliteStores`.
*
* ## Lifecycle соединения
*
* Три формы конструктора с разной семантикой владения:
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
* где один connection используется многими store'ами и закрывается bundle'ом.
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
* его в [close].
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
* закрывает его в [close].
*
* ## Миграция
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
* прогоняют миграцию одной и той же схемы по очереди.
*
* Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
* GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
* parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
* (см. [pw.binom.db.ksqlite.StmtHolder]).
*
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом.
*/
class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableJournalStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`), где один
* connection используется многими store'ами и закрывается bundle'ом.
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_MESSAGE}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
ORDER BY ${Schema.COL_CREATED_AT} ASC, ${Schema.COL_ID} ASC
LIMIT ? OFFSET ?
""".trimIndent()
)
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record)
mutex.withLock {
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, record.id)
insertStmt.bindText(2, record.conversationId)
insertStmt.bindText(3, kind)
insertStmt.bindText(4, payload)
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
listStmt.bindLong(2, after.toEpochMilliseconds())
listStmt.bindLong(3, limit.toLong())
listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(rs.toMessageRecord(json))
}
}
out
}
}
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
if (ownsConnection) {
connection.close()
}
}
companion object {
/**
* Открывает in-memory соединение через [SQLiteConnection.memory] и
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
* runtime.
*/
fun memory(name: String? = null) =
KsqliteJournalStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
}
@@ -0,0 +1,251 @@
package pw.binom.agentik.journal.ksqlite
import kotlin.time.Clock
import kotlin.time.Instant
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.db.ksqlite.SQLiteConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
* в [Schema] (миграция через PRAGMA user_version) — этот класс только
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
*
* Каскадное удаление связанных данных (message + working_memory) делает
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
* store знает только про свою таблицу.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
* store НЕ закрывает его в [close] (используется shared-connection
* bundle'ом `SqliteStores.assemble`).
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
* в [close].
*/
class KsqliteMutableConversationStore private constructor(
private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableConversationStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`).
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex()
// pre-prepare всех statement'ов — аналогично KsqliteMessageStore (см.
// KDoc там — почему GC-finalize на StmtHolder'е роняет JVM, если stmt
// живёт после закрытия connection).
private val existsStmt = connection.prepare(
"SELECT 1 FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
)
private val updateStmt = connection.prepare(
"""
UPDATE ${Schema.TABLE_CONVERSATION}
SET ${Schema.COL_TITLE} = ?, ${Schema.COL_IS_TEMPORAL} = ?, ${Schema.COL_UPDATED_AT} = ?
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
private val insertStmt = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_CONVERSATION}
(${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val getStmt = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT}
FROM ${Schema.TABLE_CONVERSATION}
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
private val deleteStmt = connection.prepare(
"DELETE FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
)
private val listStmt = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_TITLE}, ${Schema.COL_IS_TEMPORAL},
${Schema.COL_CREATED_AT}, ${Schema.COL_UPDATED_AT}
FROM ${Schema.TABLE_CONVERSATION}
WHERE ${Schema.COL_IS_TEMPORAL} = 0
ORDER BY ${Schema.COL_UPDATED_AT} DESC
LIMIT ? OFFSET ?
""".trimIndent()
)
private val renameStmt = connection.prepare(
"""
UPDATE ${Schema.TABLE_CONVERSATION}
SET ${Schema.COL_TITLE} = ?, ${Schema.COL_UPDATED_AT} = ?
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
private val renameUpdatedAtStmt = connection.prepare(
"SELECT ${Schema.COL_UPDATED_AT} FROM ${Schema.TABLE_CONVERSATION} WHERE ${Schema.COL_ID} = ?"
)
private val touchStmt = connection.prepare(
"""
UPDATE ${Schema.TABLE_CONVERSATION}
SET ${Schema.COL_UPDATED_AT} = ?
WHERE ${Schema.COL_ID} = ?
""".trimIndent()
)
override suspend fun upsert(record: ConversationRecord): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
val exists = execExists(record.id)
if (exists) {
updateStmt.reset()
updateStmt.clearBindings()
val t = record.title
if (t != null) updateStmt.bindText(1, t) else updateStmt.bindNull(1)
updateStmt.bindInt(2, if (record.isTemporal) 1 else 0)
updateStmt.bindLong(3, record.updatedAt.toEpochMilliseconds())
updateStmt.bindText(4, record.id)
updateStmt.executeUpdate()
} else {
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, record.id)
val title = record.title
if (title != null) insertStmt.bindText(2, title) else insertStmt.bindNull(2)
insertStmt.bindInt(3, if (record.isTemporal) 1 else 0)
insertStmt.bindLong(4, record.createdAt.toEpochMilliseconds())
insertStmt.bindLong(5, record.updatedAt.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
}
override suspend fun get(id: String): ConversationRecord? = withContext(Dispatchers.Default) {
mutex.withLock {
getStmt.reset()
getStmt.clearBindings()
getStmt.bindText(1, id)
getStmt.executeQuery().use { rs ->
if (rs.next()) rs.toRecord() else null
}
}
}
override suspend fun delete(id: String): Boolean = withContext(Dispatchers.Default) {
mutex.withLock {
// Проверяем существование через raw query, НЕ через get() — get() тоже
// берёт mutex (не реентрант), что привело бы к deadlock.
if (!execExists(id)) return@withContext false
deleteStmt.reset()
deleteStmt.clearBindings()
deleteStmt.bindText(1, id)
deleteStmt.executeUpdate()
true
}
}
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindLong(1, limit.toLong())
listStmt.bindLong(2, offset.toLong())
val result = mutableListOf<ConversationRecord>()
listStmt.executeQuery().use { rs ->
while (rs.next()) result.add(rs.toRecord())
}
result
}
}
override suspend fun rename(id: String, title: String?): Instant? = withContext(Dispatchers.Default) {
mutex.withLock {
val nowMs = Clock.System.now().toEpochMilliseconds()
renameStmt.reset()
renameStmt.clearBindings()
if (title != null) renameStmt.bindText(1, title) else renameStmt.bindNull(1)
renameStmt.bindLong(2, nowMs)
renameStmt.bindText(3, id)
renameStmt.executeUpdate()
renameUpdatedAtStmt.reset()
renameUpdatedAtStmt.clearBindings()
renameUpdatedAtStmt.bindText(1, id)
renameUpdatedAtStmt.executeQuery().use { rs ->
if (rs.next()) Instant.fromEpochMilliseconds(rs.getLong(0)!!) else null
}
}
}
override suspend fun touch(id: String, now: Instant): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
touchStmt.reset()
touchStmt.clearBindings()
touchStmt.bindLong(1, now.toEpochMilliseconds())
touchStmt.bindText(2, id)
touchStmt.executeUpdate()
}
}
override fun close() {
existsStmt.close()
updateStmt.close()
insertStmt.close()
getStmt.close()
deleteStmt.close()
listStmt.close()
renameStmt.close()
renameUpdatedAtStmt.close()
touchStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteMutableConversationStore =
KsqliteMutableConversationStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
}
private fun execExists(id: String): Boolean {
existsStmt.reset()
existsStmt.clearBindings()
existsStmt.bindText(1, id)
existsStmt.executeQuery().use { rs -> return rs.next() }
}
private fun pw.binom.db.ksqlite.SQLiteResultSet.toRecord(): ConversationRecord = ConversationRecord(
id = getText(0)!!,
title = getText(1),
isTemporal = (getInt(2) ?: 0) != 0,
createdAt = Instant.fromEpochMilliseconds(getLong(3)!!),
updatedAt = Instant.fromEpochMilliseconds(getLong(4)!!),
)
}
@@ -0,0 +1,87 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.decodeBodyPayload
import pw.binom.agentik.journal.encodeBodyPayload
import pw.binom.db.ksqlite.SQLiteResultSet
import kotlin.time.Instant
/**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
*
* `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
* не в каком-то внешнем общем модуле.
*/
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
content = record.content,
context = record.context,
)
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
content = record.content,
tokens = record.tokens,
)
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(),
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
)
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
ResultPayload.serializer(),
ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
)
is MessageRecord.Error -> "error" to Json.encodeToString(
ErrorPayload.serializer(),
ErrorPayload(message = record.message, code = record.code),
)
}
internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
val id = getText(0)!!
val convId = getText(1)!!
val kind = getText(2)!!
val payload = getText(3)!!
val createdAt = Instant.fromEpochMilliseconds(getLong(4)!!)
return when (kind) {
"user" -> {
val d = decodeBodyPayload(payload)
MessageRecord.UserMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, context = d.context)
}
"assistant" -> {
val d = decodeBodyPayload(payload)
MessageRecord.AssistantMessage(id = id, conversationId = convId, content = d.content, createdAt = createdAt, tokens = d.tokens)
}
"tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload)
MessageRecord.ToolCall(id = id, conversationId = convId, toolName = p.name, toolTitle = p.title, toolArgsJson = p.argsJson, createdAt = createdAt)
}
"tool_result" -> {
val p = Json.decodeFromString(ResultPayload.serializer(), payload)
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
}
"error" -> {
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
MessageRecord.Error(id = id, conversationId = convId, message = p.message, code = p.code, createdAt = createdAt)
}
else -> error("Unknown message kind in audit log: $kind")
}
}
@kotlinx.serialization.Serializable
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
/**
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
* без поля десериализуются как `null`.
*/
@kotlinx.serialization.Serializable
internal data class ResultPayload(
val toolCallId: String,
val toolName: String? = null,
val result: String?,
)
@kotlinx.serialization.Serializable
internal data class ErrorPayload(val message: String, val code: String?)
@@ -0,0 +1,99 @@
package pw.binom.agentik.journal.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
*
* Владеет двумя таблицами:
* - `conversation` — реестр диалогов агента (см. ConversationRecord);
* - `message` — append-only audit log сообщений диалогов.
*
* `working_memory` и `reflection` живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблицы ─────
const val TABLE_CONVERSATION = "conversation"
const val TABLE_MESSAGE = "message"
// ───── Колонки conversation ─────
const val COL_ID = "id"
const val COL_TITLE = "title"
const val COL_IS_TEMPORAL = "is_temporal"
const val COL_CREATED_AT = "created_at"
const val COL_UPDATED_AT = "updated_at"
// ───── Колонки message ─────
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
// ───── Индексы ─────
const val IDX_CONV_UPDATED = "idx_conv_updated"
const val IDX_MSG_CONV = "idx_msg_conv"
private val v1ConversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_TITLE TEXT,
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
"""
private val v1MessageDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
"""
private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
-- сортировка по created_at (используется list(), cascade-clear, etc.)
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
"""
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Гарантии:
* - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* уже-мигрированной БД;
* - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной.
*
**NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
* user_version как gate перестал работать — два модуля ставят его в 1,
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
*/
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(v1ConversationDdl)
conn.exec(v1MessageDdl)
conn.exec(v1IndexesDdl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
}
@@ -0,0 +1,99 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.TurnTokens
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
* SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
* прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteJournalStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteJournalStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
store = KsqliteJournalStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
@Test
fun testAppendUserAndRetrieve() = runTest {
store.append(MessageRecord.UserMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hello")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
context = null,
))
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0]
assertEquals("m1", msg.id)
assertEquals(MessageRecord.UserMessage::class, msg::class)
}
@Test
fun testAppendAssistantWithTokens() = runTest {
store.append(MessageRecord.AssistantMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hi")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
tokens = TurnTokens(input = 50, output = 30),
))
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0] as MessageRecord.AssistantMessage
assertEquals(TurnTokens(input = 50, output = 30), msg.tokens)
}
@Test
fun testListAfterFiltersByTimestamp() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
val after = store.list("conv1", after = t1, offset = 0, limit = 10)
assertEquals(2, after.size)
assertEquals(listOf("m2", "m3"), after.map { it.id })
}
@Test
fun testListFlowReturnsAllInOrder() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..3) store.append(
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
)
assertEquals(listOf("m1", "m2", "m3"), store.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
}
@Test
fun testClearRemovesByConversation() = runTest {
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), Instant.parse("2026-09-15T10:00:00Z"), null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), Instant.parse("2026-09-15T10:00:00Z"), null))
store.clear("conv1")
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
}
}
@@ -0,0 +1,146 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Тесты для [KsqliteMutableConversationStore]. Автономная фикстура —
* `SQLiteConnection.memory(...)` + конструктор `KsqliteMutableConversationStore(connection)`.
* Store сам прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteMutableConversationStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteMutableConversationStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("conv-${kotlin.random.Random.nextLong()}")
store = KsqliteMutableConversationStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun rec(id: String, title: String? = null, ts: Instant = Instant.parse("2026-09-15T10:00:00Z")) =
ConversationRecord(id, title, false, ts, ts)
@Test
fun testUpsertAndGetRoundtrip() = runTest {
store.upsert(rec("c1", "test"))
assertEquals(rec("c1", "test"), store.get("c1"))
}
@Test
fun testGetReturnsNullForMissing() = runTest {
assertNull(store.get("nope"))
}
@Test
fun testDeleteRemovesAndReturnsTrue() = runTest {
store.upsert(rec("c1"))
assertTrue(store.delete("c1"))
assertNull(store.get("c1"))
assertEquals(false, store.delete("c1"))
}
@Test
fun testListSortsByUpdatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(rec("c1", ts = t0))
store.upsert(rec("c2", ts = t0))
store.upsert(rec("c3", ts = t0))
store.upsert(rec("c4", ts = t0))
store.touch("c2", t0 + Duration.parse("PT60S"))
store.touch("c3", t0 + Duration.parse("PT120S"))
store.touch("c4", t0 + Duration.parse("PT180S"))
val page = store.list(offset = 0, limit = 4)
assertEquals(listOf("c4", "c3", "c2", "c1"), page.map { it.id })
}
@Test
fun testListRespectsOffsetAndLimit() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..5) store.upsert(rec("c$i", ts = t0 + Duration.parse("PT${i}S")))
val p0 = store.list(offset = 0, limit = 2)
assertEquals(2, p0.size)
val p2 = store.list(offset = 4, limit = 2)
assertEquals(1, p2.size)
}
@Test
fun testRenameUpdatesTitleAndUpdatedAt() = runTest {
store.upsert(rec("c1"))
val newTs = store.rename("c1", "new title")
assertNotNull(newTs)
assertEquals("new title", store.get("c1")?.title)
}
@Test
fun testRenameWithNullClearsTitle() = runTest {
store.upsert(rec("c1", "old"))
store.rename("c1", null)
assertNull(store.get("c1")?.title)
}
@Test
fun testRenameReturnsNullForMissing() = runTest {
assertNull(store.rename("nope", "x"))
}
@Test
fun testTouchUpdatesUpdatedAtOnly() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
store.touch("c1", t1)
val got = store.get("c1")
assertEquals("title", got?.title)
assertEquals(t1, got?.updatedAt)
assertEquals(t0, got?.createdAt)
}
@Test
fun testMemoryFactoryAutoMigratesSchema() = runTest {
// Smoke-test: .memory() companion-фабрика должна прогнать Schema.migrate()
// автоматически. Если бы миграция не сработала — storePreparedStatement'ы
// упали бы на `prepare failed: no such table: conversation` ещё в конструкторе.
val owned = KsqliteMutableConversationStore.memory("conv-auto-${kotlin.random.Random.nextLong()}")
try {
owned.upsert(rec("c1", "hello"))
assertEquals("hello", owned.get("c1")?.title)
} finally {
owned.close()
}
}
@Test
fun testExternalConnectionConstructorAlsoMigrates() = runTest {
// Внешний конструктор `(connection)` ТОЖЕ мигрирует (Schema.migrate idempotent).
// Caller может не звать Schema.migrate перед конструктором.
val externalConn = SQLiteConnection.memory("conv-external-${kotlin.random.Random.nextLong()}")
val s = KsqliteMutableConversationStore(externalConn)
try {
s.upsert(rec("c1"))
assertNotNull(s.get("c1"))
} finally {
s.close()
externalConn.close()
}
}
}
@@ -0,0 +1,142 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Тесты на Schema.migrate() в `:journal-ksqlite`:
* - fresh DB → создаются `conversation` + `message` + индексы
* (`idx_conv_updated`, `idx_msg_conv`);
* - уже мигрированная БД → migrate() идемпотентен (no-op);
* - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
* migrate() приводит её в боевое состояние;
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
* делают full-scan по message.
*
* `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
* в `pw.binom.agentik.reflection.ksqlite`.
*/
class SchemaMigrationTest {
@Test
fun `fresh DB gets conversation and message tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
for (table in listOf(
Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE,
)) {
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
}
for (index in listOf(
Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_CONV,
)) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
}
} finally {
conn.close()
}
}
@Test
fun `migrate is idempotent on already-migrated DB`() = runTest {
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
// повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn)
Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
} finally {
conn.close()
}
}
@Test
fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
val convStore = KsqliteMutableConversationStore(conn)
val msgStore = KsqliteJournalStore(conn)
try {
convStore.upsert(
ConversationRecord(
id = "c1", title = "t", isTemporal = false,
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
)
)
msgStore.append(
MessageRecord.UserMessage(
id = "m1", conversationId = "c1",
content = listOf(Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
)
)
val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size)
assertEquals("m1", got[0].id)
} finally {
convStore.close()
msgStore.close()
}
}
@Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
val cols = indexColumns(conn, Schema.IDX_MSG_CONV)
assertEquals(listOf(Schema.COL_CONVERSATION_ID, Schema.COL_CREATED_AT), cols)
} finally {
conn.close()
}
}
private fun tableExists(conn: SQLiteConnection, name: String): Boolean {
conn.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"
).use { stmt ->
stmt.bindText(1, name)
stmt.executeQuery().use { rs -> return rs.next() }
}
}
private fun indexExists(conn: SQLiteConnection, name: String): Boolean {
conn.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?"
).use { stmt ->
stmt.bindText(1, name)
stmt.executeQuery().use { rs -> return rs.next() }
}
}
private fun indexColumns(conn: SQLiteConnection, indexName: String): List<String> {
// PRAGMA index_info возвращает одну строку на колонку индекса
// (seqno, cid, name). Параметризовать через `?` нельзя — собираем
// строку (name — контролируемая константа, не user input).
val cols = mutableListOf<String>()
conn.prepare("PRAGMA index_info($indexName)").use { stmt ->
stmt.executeQuery().use { rs ->
while (rs.next()) {
rs.getText(2)?.let(cols::add)
}
}
}
return cols
}
}
+48
View File
@@ -0,0 +1,48 @@
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
//
// KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
// `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":memory-api"))
api(project(":journal-api"))
api(project(":reflection-api"))
api(project(":context-api"))
api(project(":skills"))
api(libs.litert.api)
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
// jvm/native variant из общего артефакта).
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.litert.tools.kotlinx.serialization)
implementation(project(":memory-md"))
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent
package pw.binom.agentik.llm.tools
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation

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