39 Commits

Author SHA1 Message Date
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
231 changed files with 9906 additions and 4472 deletions
+14 -9
View File
@@ -17,6 +17,14 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true 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: jobs:
build-jvm: build-jvm:
name: JVM build + tests name: JVM build + tests
@@ -68,12 +76,9 @@ jobs:
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \ test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)" && echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
- name: Upload shadowJars # Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
uses: actions/upload-artifact@v4 # @actions/artifact v2, который на GHES/Gitea-раннере падает с
with: # "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
name: agentik-jars # и валит весь джоб уже ПОСЛЕ успешной сборки и зелёных тестов.
path: | # У соседних репо (asr-kmp, litert-kmp) артефакты наружу тоже не выгружаются —
standalone/build/libs/standalone-*-all.jar # проверка сборки ограничивается test -f на jar (шаги выше).
agentik-cli/build/libs/agentik-cli-*-all.jar
if-no-files-found: error
retention-days: 7
+19 -46
View File
@@ -8,10 +8,13 @@
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API # :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
# отдельно от этого workflow. # отдельно от этого workflow.
# #
# Требуемые Gitea Action Variables: # Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
# BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/ # публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
# Требуемые Gitea Action Secrets: # версия берётся из тега каждый раз.
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды 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 name: release
on: on:
@@ -22,6 +25,15 @@ concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false 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: jobs:
publish-libraries: publish-libraries:
name: Publish KMP libraries → caffeine Nexus name: Publish KMP libraries → caffeine Nexus
@@ -31,46 +43,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup JDK 21 - name: Publish libraries (all KMP targets, all modules) to Nexus
uses: actions/setup-java@v4 uses: https://git.binom.pw/subochev/devops/publish@main
with: with:
java-version: '21' version: ${{ gitea.ref_name }}
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
+5
View File
@@ -18,8 +18,13 @@ out/
# Local tooling (Magic Context, IDE plugins, MCP configs) # Local tooling (Magic Context, IDE plugins, MCP configs)
.cortexkit/ .cortexkit/
# opencode CLI local config (per-machine, не коммитим)
config.json
.veai/ .veai/
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
.tasks/
# Runtime / test artifacts # Runtime / test artifacts
agentik.db agentik.db
agentik.db-shm agentik.db-shm
+37
View File
@@ -0,0 +1,37 @@
Status of message-log-api migration:
DONE:
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
- Content.kt (sealed: Text, Image)
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
- MessageStore.kt (interface, TokenStats, MessageEvent)
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
6. Wrote /tmp/run_rename.sh that runs the python script.
PENDING:
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
- Verify build compiles (run standalone tests).
Files that need import updates (per search):
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
- standalone/src/jvmMain/.../ToolDispatcher.kt
- standalone/src/jvmMain/.../ConversationLoop.kt
- standalone/src/jvmTest/.../ChatAgentTest.kt
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
Workaround needed before continuing the migration.
+1 -1
View File
@@ -77,7 +77,7 @@ budget exhaustion, registry filter, parallel dispatch.
## Чего здесь НЕТ ## Чего здесь НЕТ
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM. - Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore` - Никакого persistent storage. Опирается на контракт `ContextStore`
(см. `:storage-core`). (см. `:storage-core`).
## Текущий статус ## Текущий статус
+3 -2
View File
@@ -21,8 +21,9 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
// :storage-core — для StorageBundle в ToolsetContext (commit 5+) api(project(":journal-api"))
api(project(":storage-core")) api(project(":reflection-api"))
api(project(":context-api"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke) // litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api) api(libs.litert.api)
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
@@ -8,5 +8,8 @@ import pw.binom.litert.LiteTool
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM) * Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`, * с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами. * чтобы избежать коллизий между разными MCP-серверами.
*
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
* должен жить рядом с другими тулами в `:agent-toolsets`.
*/ */
data class NamedTool(val name: String, val tool: LiteTool) data class NamedTool(val name: String, val tool: LiteTool)
@@ -4,7 +4,7 @@ package pw.binom.agentik.toolsets
* Контекст, который тулсеты получают при активации. * Контекст, который тулсеты получают при активации.
* *
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда * В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета * добавятся `SkillStore` и пр., чтобы тулы внутри тулсета
* могли читать/писать сообщения и память. * могли читать/писать сообщения и память.
* *
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой * Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
+1
View File
@@ -41,6 +41,7 @@ kotlin {
implementation(libs.kotlinx.cli) implementation(libs.kotlinx.cli)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.cio)
} }
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения): // :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
// весь код, включая platformEnv, лежит в commonMain. // весь код, включая platformEnv, лежит в commonMain.
@@ -0,0 +1,24 @@
package pw.binom.agentik.cli
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import pw.binom.agentik.client.applyAgentikDefaults
/**
* HTTP-клиент CLI: движок CIO + конфигурация agentik.
*
* Движок выбирается здесь, а не в `:client`: библиотека не выбирает транспорт за
* потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64)
* покрываются CIO.
*
* `requestTimeout = 0` — отключение встроенного request-таймаута CIO;
* defense-in-depth против обрыва долгих SSE-idle (основная защита —
* `noSseReadTimeout` в `:client`).
*
* [token] = `null` — авторизация выключена.
*/
internal fun defaultCliHttpClient(token: String? = null): HttpClient =
HttpClient(CIO) {
applyAgentikDefaults(token)
engine { requestTimeout = 0 }
}
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") { class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
val id by argument(ArgType.String, description = "ID диалога") val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val ok = agent.deleteConversation(id) val ok = agent.deleteConversation(id)
if (ok) println("deleted: $id") else println("conversation not found: $id") if (ok) println("deleted: $id") else println("conversation not found: $id")
} }
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import kotlinx.cli.default import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
@@ -10,7 +11,7 @@ class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов а
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE) val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE)) val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
if (convs.isEmpty()) { if (convs.isEmpty()) {
println("(no conversations)") println("(no conversations)")
@@ -3,13 +3,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import kotlinx.cli.default import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") { class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false) val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.createConversation(temp = temp) val conv = agent.createConversation(temp = temp)
println(conv.id) println(conv.id)
} }
@@ -2,6 +2,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") { class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
@@ -9,7 +10,7 @@ class ConvRenameSubcommand : ConvSubcommand("rename", "Переименоват
val title by argument(ArgType.String, description = "Новое название") val title by argument(ArgType.String, description = "Новое название")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") { class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
val id by argument(ArgType.String, description = "ID диалога") val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") { class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
val id by argument(ArgType.String, description = "ID диалога") val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType import kotlinx.cli.ArgType
import kotlinx.cli.default import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
@@ -13,7 +14,7 @@ class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообще
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100) val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -7,9 +7,10 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.agentik.cli.AgentikSubcommand import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.cli.defaultCliHttpClient
import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant import kotlin.time.Instant
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") { class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
@@ -17,7 +18,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg() val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
override fun execute() = kotlinx.coroutines.runBlocking { override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient())
val conv = agent.getConversation(id) ?: run { val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id") println("conversation not found: $id")
return@runBlocking return@runBlocking
@@ -46,7 +46,11 @@ internal class TuiBackend(
state.postSystem("подключено к ${state.config.server}") state.postSystem("подключено к ${state.config.server}")
scope.launch { scope.launch {
try { try {
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ } // agent.outbox.agentEvents(after) возвращает Flow<CommonEvent.Agent>;
// распаковываем .event для получения AgentEvent (раньше был
// отдельный метод agent.events(), теперь упразднён — события
// живут в outbox-сущности).
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
} catch (_: kotlinx.coroutines.CancellationException) { } catch (_: kotlinx.coroutines.CancellationException) {
// штатная отмена при закрытии UI // штатная отмена при закрытии UI
} catch (e: Exception) { } catch (e: Exception) {
@@ -3,8 +3,9 @@ package pw.binom.agentik.tui
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event import pw.binom.agentik.proto.Event
@@ -25,6 +26,16 @@ internal class FakeAgent(
private set private set
val conversations = mutableListOf<FakeConversation>() val conversations = mutableListOf<FakeConversation>()
// Storage handles не используются тестами TuiBackend — тесты проверяют
// маршрутизацию Conversation.events в UI state. Outbox stub-ы возвращают
// emptyFlow, journal — error-on-access (никто не должен его трогать).
override val journal: JournalStore = error("journal not used in TuiBackend tests")
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.proto.CommonEvent>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {}
}
override fun createConversation(temp: Boolean): Conversation { override fun createConversation(temp: Boolean): Conversation {
createCount++ createCount++
val c = conversationFactory() val c = conversationFactory()
@@ -40,8 +51,6 @@ internal class FakeAgent(
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
conversations.toList() conversations.toList()
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
} }
/** /**
@@ -205,7 +205,7 @@ class TuiBackendTest {
} }
@Test @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 cfg = fixtureConfig()
val state = AppState(cfg) val state = AppState(cfg)
val agent = FakeAgent(conversationFactory = { error("server kaboom") }) val agent = FakeAgent(conversationFactory = { error("server kaboom") })
+298 -68
View File
@@ -1,101 +1,331 @@
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native) # `:client` — Ktor-клиент к `:server` (KMP, jvm + native)
## Что это Тонкий HTTP-клиент к `:server`-фасаду + локальные примитивы, чтобы
собирать свои клиенты (UI, CLI, parent-агенты, A2A-bridge) без бойлерплейта
про HTTP, JSON, SSE и lifecycle `Conversation`.
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) + ## Что есть
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
интерфейсы `:proto`:
- `AgentikAgent(id, baseUrl)` — entry-point фабрики. - `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает
- `AgentClient` — список и lifecycle диалогов. `Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри
- `ConversationClient` — `send()`, `events()`, `interrupt()`, из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`).
`getMessages()`, `rename()`, `close()`. - `Agent`: `createConversation` / `getConversation` / `getConversations` /
- Внутренний парсер SSE → `Flow<Event>`. `deleteConversation` / `journal` / `outbox` / `close`.
- `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow<Event>`)
/ `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`.
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate, `Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
с теми же типами, что и сервер. Один и тот же клиент работает на вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
JVM, iOS, macOS, Linux, Windows.
## Где используется ## Подключение
- `:agentik-cli` — REPL.
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
## Как подключить
```kotlin ```kotlin
// build.gradle.kts // build.gradle.kts
kotlin { dependencies {
sourceSets.commonMain.dependencies { api("pw.binom.agentik:client:0.1.0")
api("pw.binom.agentik:client:0.1.0") // Движок — на твой выбор (один из):
} implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native
} implementation("io.ktor:ktor-client-okhttp:3.x") // JVM
implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS
// ваш код: // Опционально — только если будешь использовать `InMemoryJournalStore`
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik") // как клиентский кэш. Свой `MutableJournalStore` — не нужен.
val conv = agent.createConversation(title = "test") api("pw.binom.agentik:journal-inmemory:0.1.0")
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
}
} }
``` ```
## Версии ## Быстрый старт: свой клиент за 5 минут
`gradle/libs.versions.toml` → `[versions] agentik-client`. Один self-contained пример: создаём агента, открываем диалог,
отправляем сообщение, печатаем streaming-ответ.
Поддерживает все KMP-таргеты, что и `:proto`.
## Примеры API
```kotlin ```kotlin
// список диалогов import pw.binom.agentik.client.AgentikAgent
agent.getConversations().collect { println(it.id to it.title) } import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.runBlocking
import kotlin.time.Clock
// live-подписка на события отдельного диалога fun main() = runBlocking {
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { } // 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри.
val agent = AgentikAgent(
id = "my-client",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
token = "s3cret", // или null, если не нужен
)
// прерывание текущего хода // 2. Открыть диалог, отправить сообщение.
conversation.interrupt() val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет")))
// история // 3. Собирать streaming-ответ.
conversation.getMessages(offset = 0).collect { msg -> conv.events(after = Clock.System.now()).collect { ev ->
when (msg) { when (ev) {
is Message.UserMessage -> println("user: ${msg.content}") is Event.StartResponse -> println("[start]")
is Message.AssistantMessage -> println("assistant: ${msg.content}") is Event.AppendText -> print(ev.body)
else -> Unit is Event.End -> println("[end]")
is Event.Error -> println("[error: ${ev.message}]")
else -> Unit
}
}
// 4. Чистый shutdown.
conv.close()
agent.close()
}
```
**Это весь клиент.** `:server` сам хранит историю, контекст, события.
Ты только получаешь типизированный `Flow<Event>` и рендеришь как хочешь.
`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто
внутри `AgentikAgent`. Один вызов — один готовый `Agent`.
### Добавить локальный кэш истории (ещё 4 строки)
```kotlin
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import kotlin.time.Instant
// Свой кэш. Хочешь SQLite/JSON/etc. — реализуй MutableJournalStore сам.
val cache = InMemoryJournalStore()
// Backfill + live-refresh в одном фоне:
launch {
agent.journal.listFlow(conv.id, Instant.DISTANT_PAST)
.collect { cache.append(it) }
}
// История — теперь из кэша, без HTTP:
val 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`). Это и есть кэширование
"без геморроя".
### Что вообще не нужно писать самому
- 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)` напрямую (он экспортирован).
### Что нужно написать самому
- UI-рендеринг `Event`'ов — это твоё (Compose/HTML/CLI).
- Диалог с пользователем — ввод текста, отображение кнопок и т.п.
- Persist кэша между запусками (если нужно) — замени `InMemoryJournalStore`
на свой `MutableJournalStore` (см. `:journal-ksqlite` как пример).
## Базовый пример: send + collect events
```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
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
val conv = agent.createConversation(temp = false)
conv.send(listOf(Content.Text("Привет, расскажи про себя")))
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 баббла, и т.п.
## Стриминг 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` хранит в RAM. Для
диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в
`:journal-ksqlite` как образец).
- **Нестандартные движковые настройки** — для `requestTimeout`,
прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
напрямую.
## Тесты ## Тесты
``` ```
./gradlew :client:jvmTest ./gradlew :client:jvmTest
``` ```
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва, Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
401/404. 401/404.
## Чего здесь НЕТ
- Никакого LLM-кода. Это просто клиент.
- Никакого persistent state. История хранится у сервера, клиент её
запрашивает через `getMessages` или подписывается через `events`.
## Текущий статус
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
но клиент совместим с любым сервером, который держит wire-контракт
`:server`.
## Известное ограничение ## Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE. terminal (TTY). Это upstream-особенность Ktor SSE.
+4 -2
View File
@@ -21,9 +21,10 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
api(project(":proto")) api(project(":proto"))
api(project(":outbox-api"))
api(project(":journal-api"))
implementation(libs.ktor.client.core) api(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
@@ -37,6 +38,7 @@ kotlin {
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.test.host) implementation(libs.ktor.server.test.host)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.server.cio) implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.sse) implementation(libs.ktor.server.sse)
} }
@@ -4,39 +4,38 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/** /**
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`. * HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
* *
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend * HttpClient создаётся внутри из переданного engine и закрывается в [close].
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа *
* POST `/conversations`. Используем `runBlocking` — это одноразовая * **Storage handles** ([journal], [outbox]) — read-only views на серверные
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам * хранилища.
* решает, что делать.
*/ */
internal class AgentClient( internal class AgentClient(
private val httpClient: HttpClient,
private val baseUrl: String,
override val id: String, override val id: String,
private val baseUrl: String,
private val httpClient: HttpClient,
) : Agent { ) : Agent {
private val agentUrl: String = baseUrl.trimEnd('/') private val agentUrl: String = baseUrl.trimEnd('/')
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
override fun createConversation(temp: Boolean): Conversation = override fun createConversation(temp: Boolean): Conversation =
runBlocking { runBlocking {
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") { val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
@@ -54,7 +53,7 @@ internal class AgentClient(
} }
override suspend fun deleteConversation(id: String): Boolean { override suspend fun deleteConversation(id: String): Boolean {
val response = httpClient.delete("$agentUrl/conversations/$id") val response: HttpResponse = httpClient.delete("$agentUrl/conversations/$id")
return response.status == HttpStatusCode.NoContent return response.status == HttpStatusCode.NoContent
} }
@@ -66,16 +65,7 @@ internal class AgentClient(
return snapshots.map { ConversationClient(httpClient, agentUrl, it) } return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
} }
override fun events(after: Instant): Flow<AgentEvent> = flow { override fun close() {
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() } httpClient.close()
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
}
} }
} }
@@ -1,46 +1,43 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngineFactory
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
/** /**
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent` * Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
* (модуль `:server`). *
* Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`,
* `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг
* [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults`
* снаружи — всё под капотом.
* *
* ``` * ```
* val client = AgentikAgent( * val agent = AgentikAgent(
* id = "my-agent", * id = "my-client",
* baseUrl = "http://localhost:8080/agentik", * baseUrl = "http://localhost:8080/agentik",
* engineFactory = CIO,
* token = "s3cret",
* ) * )
* val conv = client.createConversation(temp = false) * val conv = agent.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi"))) * conv.send(listOf(Content.Text("hi")))
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... } * conv.events(Instant.DISTANT_PAST).collect { ... }
* agent.close() // закрывает HttpClient
* ``` * ```
* *
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность * [id] пробрасывается в `Agent.id` — сервер про идентичность агента не знает,
* агента не знает, поэтому клиент должен её знать сам (или взять из * поэтому клиент должен её знать сам (или взять из конфига).
* конфига).
* *
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый * **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой. * HttpClient (идемпотентно). После этого `createConversation` /
* `getConversation` etc. не определены.
*/ */
fun AgentikAgent( fun AgentikAgent(
id: String, id: String,
baseUrl: String, baseUrl: String,
httpClient: HttpClient = defaultAgentikHttpClient(), engineFactory: HttpClientEngineFactory<*>,
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) token: String? = null,
): Agent = AgentClient(
/** id = id,
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse]) baseUrl = baseUrl,
* живёт в общем коде и плагина `SSEClientContent` не требует. httpClient = agentikHttpClient(engineFactory = engineFactory, token = token),
* )
* **Платформы:**
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
* `ConversationClient.events`/`AgentClient.events` уже ставят
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
*
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
* Реализация — в [HttpClientFactory.kt].
*/
@@ -1,18 +1,32 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 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 import io.ktor.serialization.kotlinx.json.json
/** /**
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli). * Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik.
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
* *
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE: * Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]). * не нужен — он передаёт engine в [AgentikAgent] и получает готовый
* [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом
* на [pw.binom.agentik.proto.Agent.close].
*
* Экспортируется для случаев, когда нужен прямой доступ к `HttpClient`
* (например, дополнительные нестандартные запросы в обход `Agent` API).
*/ */
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) { fun agentikHttpClient(
engine { requestTimeout = 0 } engineFactory: HttpClientEngineFactory<*>,
token: String? = null,
): HttpClient = HttpClient(engineFactory) {
install(ContentNegotiation) { json(agentikJson) } install(ContentNegotiation) { json(agentikJson) }
if (token != null) {
install(DefaultRequest) {
header(HttpHeaders.Authorization, "Bearer $token")
}
}
} }
@@ -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).
}
}
@@ -12,7 +12,7 @@ import io.ktor.client.request.HttpRequestBuilder
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным * использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет * (`HttpRequestBuilder.supportsRequestTimeout` проверяет
* `body is SSEClientContent`, а у нас тело — обычный GET без тела). * `body is SSEClientContent`, а у нас тело — обычный GET без тела).
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию * Без capability встроенный `HttpTimeoutPlugin.requestTimeoutMillis` (по умолчанию
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд. * **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
* *
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при * Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
@@ -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)
}
}
}
+38
View File
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// Public API для runtime context агента (compaction, order_idx, summary entries).
// Зависит от :journal-api для типов `Content` / `MessageContext` (audit-log
// payload'ы, которые рабочая память ссылает).
//
// НЕ нужен тонким клиентам — только серверному рантайму (`:standalone`, `:agentik-cli`,
// будущий `:android-agent` core).
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":journal-api"))
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage package pw.binom.agentik.context
import kotlin.time.Instant import kotlin.time.Instant
@@ -7,7 +7,7 @@ import kotlin.time.Instant
* *
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row. * Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
* Агент не должен с этим типом работать напрямую — он работает с * Агент не должен с этим типом работать напрямую — он работает с
* [WorkingMemoryEntry] через [WorkingMemoryStore]. * [WorkingMemoryEntry] через [ContextStore].
*/ */
data class WorkingMemoryRow( data class WorkingMemoryRow(
val id: String, val id: String,
@@ -28,7 +28,7 @@ data class WorkingMemoryRow(
* *
* Суммаризация / чистка — один атомарный вызов [compact]. * Суммаризация / чистка — один атомарный вызов [compact].
*/ */
interface WorkingMemoryStore : AutoCloseable { interface ContextStore : AutoCloseable {
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */ /** Добавить запись в конец working memory (новый максимальный `order_idx`). */
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
@@ -1,7 +1,9 @@
package pw.binom.agentik.storage package pw.binom.agentik.context
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageContext
/** /**
* Запись в working memory диалога: ровно то, что агент сейчас видит в * Запись в working memory диалога: ровно то, что агент сейчас видит в
+43
View File
@@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// KMP-реализация :context-api (ContextStore) поверх ksqlite.
// Минимальная — только таблица `working_memory` + 2 индекса по ней.
// Остальные таблицы (`conversation`, `message`, `reflection`) живут в
// других ksqlite-модулях; этот модуль не претендует на полную схему
// агента.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json)
api(project(":context-api"))
api(project(":journal-api"))
api(project(":reflection-api"))
// :context-api ссылается на Content / MessageContext из
// :message-log-api (старый canonical). Транзитивно через api,
// но фиксируем явно чтобы тестовый код видел Content без
// обхода через :context-api.
// api(project(":message-log-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,200 @@
package pw.binom.agentik.context.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.WorkingMemoryRow
import pw.binom.agentik.journal.Ids
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [ContextStore] (таблица `working_memory`).
*
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore]
* из `:storage-ksqlite`, но:
* - лежит в собственном модуле `:context-ksqlite`;
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
* теперь главный класс — `ContextStore`); сами типы строк
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно.
*/
class KsqliteContextStore(
private val connection: SQLiteConnection,
) : ContextStore {
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
// pre-prepare (см. KsqliteMessageStore KDoc — почему это критично против
// SIGSEGV в StmtHolder.finalize на закрытой connection).
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
ORDER BY ${Schema.COL_ORDER_IDX} ASC
""".trimIndent()
)
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_WORKING_MEMORY} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val maxOrderIdxStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COALESCE(MAX(${Schema.COL_ORDER_IDX}), 0)
FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
""".trimIndent()
)
private val dropFromIdxStmt: SQLitePreparedStatement = connection.prepare(
"""
DELETE FROM ${Schema.TABLE_WORKING_MEMORY}
WHERE ${Schema.COL_CONVERSATION_ID} = ? AND ${Schema.COL_ORDER_IDX} >= ?
""".trimIndent()
)
private val insertSummaryStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_WORKING_MEMORY}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_ORDER_IDX},
${Schema.COL_SOURCE_MESSAGE_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, NULL, ?, ?, ?)
""".trimIndent()
)
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
val newIdx = maxOrderIdx(conversationId) + 1
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, Ids.new("wm"))
insertStmt.bindText(2, conversationId)
insertStmt.bindLong(3, newIdx)
val srcId = entry.sourceMessageId
if (srcId != null) insertStmt.bindText(4, srcId) else insertStmt.bindNull(4)
insertStmt.bindText(5, entryKind(entry))
insertStmt.bindText(6, json.encodeToString(WorkingMemoryEntry.serializer(), entry))
insertStmt.bindLong(7, now.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(conversationId: String): List<WorkingMemoryRow> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
val out = mutableListOf<WorkingMemoryRow>()
listStmt.executeQuery().use { rs ->
while (rs.next()) {
out.add(
WorkingMemoryRow(
id = rs.getText(0)!!,
conversationId = rs.getText(1)!!,
orderIdx = rs.getLong(2)!!,
sourceMessageId = rs.getText(3),
entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), rs.getText(4)!!),
createdAt = Instant.fromEpochMilliseconds(rs.getLong(5)!!),
)
)
}
}
out
}
}
override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String?,
): Long = withContext(Dispatchers.Default) {
mutex.withLock {
var newMax = 0L
val nowMs = Clock.System.now().toEpochMilliseconds()
val summaryId = Ids.new("wm")
connection.exec("BEGIN")
try {
dropFromIdxStmt.reset()
dropFromIdxStmt.clearBindings()
dropFromIdxStmt.bindText(1, conversationId)
dropFromIdxStmt.bindLong(2, dropFromOrderIdx)
dropFromIdxStmt.executeUpdate()
if (!summaryText.isNullOrBlank()) {
val afterDelete = maxOrderIdx(conversationId)
val newIdx = afterDelete + 1
insertSummaryStmt.reset()
insertSummaryStmt.clearBindings()
insertSummaryStmt.bindText(1, summaryId)
insertSummaryStmt.bindText(2, conversationId)
insertSummaryStmt.bindLong(3, newIdx)
insertSummaryStmt.bindText(4, "summary")
insertSummaryStmt.bindText(5, json.encodeToString(WorkingMemoryEntry.serializer(), WorkingMemoryEntry.Summary(text = summaryText)))
insertSummaryStmt.bindLong(6, nowMs)
insertSummaryStmt.executeUpdate()
newMax = newIdx
} else {
newMax = maxOrderIdx(conversationId)
}
connection.exec("COMMIT")
} catch (t: Throwable) {
runCatching { connection.exec("ROLLBACK") }
throw t
}
newMax
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
maxOrderIdxStmt.close()
dropFromIdxStmt.close()
insertSummaryStmt.close()
}
private fun maxOrderIdx(conversationId: String): Long {
maxOrderIdxStmt.reset()
maxOrderIdxStmt.clearBindings()
maxOrderIdxStmt.bindText(1, conversationId)
maxOrderIdxStmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0) ?: 0L
}
return 0L
}
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
is WorkingMemoryEntry.User -> "user"
is WorkingMemoryEntry.Assistant -> "assistant"
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
is WorkingMemoryEntry.Summary -> "summary"
}
}
@@ -0,0 +1,98 @@
package pw.binom.agentik.context.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:context-api`.
*
* Минимум — только то, что относится к `working_memory` (реализация
* [KsqliteContextStore]). Остальные таблицы агента (`conversation`,
* `message`, `reflection`) живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
const val TABLE_WORKING_MEMORY = "working_memory"
// ───── Колонки ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_ORDER_IDX = "order_idx"
const val COL_SOURCE_MESSAGE_ID = "source_message_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ─────
const val IDX_WM_UNIQUE = "idx_wm_unique"
const val IDX_WM_CONV = "idx_wm_conv"
private val v1Ddl = """
CREATE TABLE IF NOT EXISTS $TABLE_WORKING_MEMORY (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_ORDER_IDX INTEGER NOT NULL,
$COL_SOURCE_MESSAGE_ID TEXT,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
""".trimIndent()
private val v1IndexesDdl = """
CREATE UNIQUE INDEX IF NOT EXISTS $IDX_WM_UNIQUE
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
CREATE INDEX IF NOT EXISTS $IDX_WM_CONV
ON $TABLE_WORKING_MEMORY($COL_CONVERSATION_ID, $COL_ORDER_IDX);
""".trimIndent()
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
*/
fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
}
@@ -0,0 +1,107 @@
package pw.binom.agentik.context.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.journal.Content
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Тесты для [KsqliteContextStore] — точная копия
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
* та же).
*
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
* автономный.
*/
class KsqliteContextStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteContextStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteContextStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun userMsg(content: String, srcId: String = "m-${content.hashCode()}"): WorkingMemoryEntry.User =
WorkingMemoryEntry.User(sourceMessageId = srcId, content = listOf(Content.Text(content)))
private fun asstMsg(content: String): WorkingMemoryEntry.Assistant =
WorkingMemoryEntry.Assistant(sourceMessageId = "m-${content.hashCode()}", content = listOf(Content.Text(content)))
@Test
fun testAppendAndListReturnsInOrder() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("first"), t)
store.append("c1", asstMsg("reply"), t)
val list = store.list("c1")
assertEquals(2, list.size)
assertEquals(1L, list[0].orderIdx)
assertEquals(2L, list[1].orderIdx)
}
@Test
fun testListIsolatesConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("c1-msg"), t)
store.append("c2", userMsg("c2-msg"), t)
assertEquals(1, store.list("c1").size)
assertEquals(1, store.list("c2").size)
}
@Test
fun testClearRemovesAllForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
store.append("c1", userMsg("b"), t)
store.clear("c1")
assertEquals(emptyList(), store.list("c1"))
}
@Test
fun testCompactDeletesAndInsertsSummary() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
store.append("c1", userMsg("b"), t)
store.append("c1", userMsg("c"), t)
// dropFromOrderIdx=2: удаляет idx=2 и idx=3 (b и c), остаётся idx=1 (a).
// Summary встаёт на idx=2 (= max(remaining)+1). Возвращает newMax=2.
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = "summary")
assertEquals(2L, newMax)
val remaining = store.list("c1")
assertEquals(2, remaining.size)
assertEquals(1L, remaining[0].orderIdx)
assertEquals(2L, remaining[1].orderIdx)
assertTrue(remaining[1].entry is WorkingMemoryEntry.Summary)
}
@Test
fun testCompactWithoutSummaryKeepsTailBelow() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", userMsg("a"), t)
// dropFromOrderIdx=2: удаляет idx >= 2, остаётся idx=1.
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
assertEquals(1L, newMax)
val remaining = store.list("c1")
assertEquals(1, remaining.size)
assertEquals(1L, remaining[0].orderIdx)
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ Main.kt
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx, - `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
summarization-вставка отложена (нужен дизайн-проработка). summarization-вставка отложена (нужен дизайн-проработка).
**`MessageStore`** — append-only аудит. На каждый ход дописываются **`JournalStore`** — append-only аудит. На каждый ход дописываются
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких `UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
update/delete кроме каскада из `ConversationStore.delete`. update/delete кроме каскада из `ConversationStore.delete`.
+2 -2
View File
@@ -175,7 +175,7 @@ fun main() {
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов). **Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
### `MessageStore` ### `JournalStore`
```kotlin ```kotlin
suspend fun append(record: MessageRecord) suspend fun append(record: MessageRecord)
@@ -183,7 +183,7 @@ suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int
suspend fun listAll(conversationId: String): List<MessageRecord> suspend fun listAll(conversationId: String): List<MessageRecord>
``` ```
### `WorkingMemoryStore` ### `ContextStore`
```kotlin ```kotlin
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
+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)
}
}
}
+10 -3
View File
@@ -10,7 +10,7 @@ litert = "8"
sqldelight = "2.3.2" sqldelight = "2.3.2"
shadow = "8.3.5" shadow = "8.3.5"
jvector = "3.0.6" jvector = "3.0.6"
text-embedding-kmp = "3.0.0-SNAPSHOT" text-embedding-kmp = "4"
kotlin-logging = "3.0.5" kotlin-logging = "3.0.5"
logback = "1.5.18" logback = "1.5.18"
mosaic = "0.18.0" mosaic = "0.18.0"
@@ -97,8 +97,15 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" } jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. --- # --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета). # `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" } # (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
# Версия 4 — первый релиз после KMP-целей, опубликован в caffeine Nexus
# через release-workflow subochev/text-embedding-kmp.
# Используется из :memory-md-vector и :memory-vector напрямую через
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
# нужный variant под target).
# `siglip` — JVM+Android only (onnx-runtime), подключается в jvmMain.
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "text-embedding-kmp" }
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" } text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). --- # --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
+29
View File
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,17 +1,12 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Часть контента сообщения на уровне хранилища. * Часть контента сообщения на уровне хранилища. Намеренно НЕ зависит от
* * `pw.binom.agentik.proto.Content` — маппинг `:proto.Content ↔ Content` живёт
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг * в `Mapping.kt` storage impl'ов.
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
* идентичны, но даёт возможность заменить transport-протокол без миграции
* таблиц.
*
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
*/ */
@Serializable @Serializable
sealed interface Content { sealed interface Content {
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlin.time.Instant import kotlin.time.Instant
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlin.time.Instant import kotlin.time.Instant
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage package pw.binom.agentik.journal
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -11,5 +11,4 @@ import kotlin.uuid.Uuid
*/ */
object Ids { object Ids {
fun new(prefix: String): String = "$prefix-${Uuid.random()}" fun new(prefix: String): String = "$prefix-${Uuid.random()}"
fun reflection(): String = new("refl")
} }
@@ -0,0 +1,41 @@
package pw.binom.agentik.journal
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Instant
/**
* Append-only audit log сообщений — read-only представление.
*
* Producer-операция [MutableJournalStore.append] находится на
* [MutableJournalStore] — этот интерфейс только для чтения, чтобы
* consumer'ы физически не могли писать в audit log.
*
* Никаких обновлений, никакого удаления (кроме каскадного вместе
* с ConversationStore.delete).
*/
interface JournalStore : AutoCloseable {
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для
* in-memory backend'ов это OK; remote/SQLite impl'ы могут override'нуть
* на `Channel` / cursor-батчинг, чтобы избежать per-page round-trip.
*/
fun listFlow(conversationId: String, after: Instant, pageSize: Int = PAGE_SIZE): Flow<MessageRecord> = flow {
var offset = 0
while (true) {
val page = list(conversationId, after, offset, pageSize)
if (page.isEmpty()) return@flow
for (rec in page) emit(rec)
if (page.size < pageSize) return@flow
offset += page.size
}
}
companion object {
const val PAGE_SIZE = 100
}
}
@@ -0,0 +1,12 @@
package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class MessageContext(
val origin: MessageOrigin,
val sourceId: String? = null,
val description: String? = null,
val metadata: JsonElement? = null,
)
@@ -0,0 +1,20 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Контекст инициации хода (кто/что и почему). Дубликат типа из `:proto` —
* живёт здесь чтобы не тащить `:proto` в слой хранения данных.
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@@ -0,0 +1,71 @@
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,
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
}
@@ -0,0 +1,17 @@
package pw.binom.agentik.journal
/**
* Mutable вариант [JournalStore] — добавляет producer-операцию [append].
*
* Этот интерфейс предназначен **только для producer'ов** (ChatAgent,
* ConversationLoop, ToolDispatcher, sub-agents, A2A-bridge).
* Consumer'ы (DebugRoutes, admin dashboards, parent agents) должны
* принимать **read-only** [JournalStore] — тогда невозможно случайно
* записать в audit log из observer'а.
*
* **Append семантика**: см. KDoc [MessageStore.append][JournalStore] —
* на этом интерфейсе (не дублируем).
*/
interface MutableJournalStore : JournalStore {
suspend fun append(record: MessageRecord)
}
@@ -0,0 +1,47 @@
package pw.binom.agentik.journal
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
private val bodyJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Serializable
data class MessageBodyPayload(
val content: List<Content>,
@SerialName("context")
val context: MessageContext? = null,
val tokens: TurnTokens? = null,
)
fun encodeBodyPayload(
content: List<Content>,
context: MessageContext? = null,
tokens: TurnTokens? = null,
): String = bodyJson.encodeToString(
MessageBodyPayload.serializer(),
MessageBodyPayload(content = content, context = context, tokens = tokens),
)
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
data class BodyDecoded(
val content: List<Content>,
val context: MessageContext?,
val tokens: TurnTokens? = null,
)
private fun readPayload(json: String): BodyDecoded {
return try {
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
BodyDecoded(p.content, p.context, p.tokens)
} catch (e: kotlinx.serialization.SerializationException) {
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
BodyDecoded(arr, null, null)
}
}
@@ -0,0 +1,18 @@
package pw.binom.agentik.journal
import kotlinx.serialization.Serializable
/**
* Token usage одного assistant turn'а.
*/
@Serializable
data class TurnTokens(
val input: Int,
val output: Int,
) {
val total: Int get() = input + output
init {
require(input >= 0) { "input tokens must be non-negative, got $input" }
require(output >= 0) { "output tokens must be non-negative, got $output" }
}
}
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [MutableJournalStore] на `MutableList` + `Mutex` — для
// тестов, dev-режима, embedded-сценариев (Android core, CLI, in-process кэш
// в клиенте) и как образец для своей реализации.
//
// `list` фильтрует по `conversationId`+`createdAt>after` и сортирует
// по `createdAt ASC`. Paging — поверх отфильтрованного списка.
//
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,68 @@
package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import kotlin.time.Instant
/**
* Простая in-memory [MutableJournalStore] для тестов, dev-режима и
* клиентских in-process кэшей.
*
* **Thread-safety**: `Mutex` поверх `MutableList<MessageRecord>`. Для
* embedded/CLI сценариев достаточно; для hot-path на сервере используйте
* [pw.binom.agentik.journal.ksqlite.KsqliteJournalStore].
*
* **Контракт `list`**: возвращает подмножество с
* `conversationId == conversationId && createdAt > after`, отсортированное
* по `createdAt ASC`. `offset/limit` — paging поверх отфильтрованного списка.
*
* **Очистка**: [clear] сбрасывает кэш (например, когда диалог удалён
* на сервере). [close] — no-op.
*
* Типичный кэш-паттерн в клиенте:
* ```
* val local = InMemoryJournalStore()
* val remote = HttpJournalStore(httpClient, baseUrl)
* // backfill + кэширование:
* remote.listFlow(convId, Instant.DISTANT_PAST).collect { local.append(it) }
* // после этого `local.list(convId, after, offset, limit)` отдаёт из кэша.
* ```
*/
class InMemoryJournalStore : MutableJournalStore {
private val mutex = Mutex()
private val records: MutableList<MessageRecord> = mutableListOf()
override suspend fun append(record: MessageRecord): Unit = mutex.withLock {
records.add(record)
}
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = mutex.withLock {
records.asSequence()
.filter { it.conversationId == conversationId && it.createdAt > after }
.sortedBy { it.createdAt }
.drop(offset)
.take(limit)
.toList()
}
/** Сбросить кэш (например, когда диалог удалён). */
suspend fun clear(): Unit = mutex.withLock {
records.clear()
}
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */
suspend fun size(): Int = mutex.withLock { records.size }
override fun close() {
// no-op: lifecycle HttpClient'а — снаружи.
}
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.MessageRecord
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class InMemoryJournalStoreTest {
private fun userMsg(id: String, convId: String, text: String, at: Instant) =
MessageRecord.UserMessage(
id = id,
conversationId = convId,
content = listOf(pw.binom.agentik.journal.Content.Text(text)),
createdAt = at,
)
@Test
fun `append then list returns records sorted by createdAt ASC`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "first", t0))
store.append(userMsg("m2", "c1", "second", t0 + 1.seconds))
store.append(userMsg("m3", "c1", "third", t0 + 2.seconds))
val all = store.list("c1", Instant.DISTANT_PAST, 0, 100)
assertEquals(3, all.size)
assertEquals(listOf("m1", "m2", "m3"), all.map { it.id })
}
@Test
fun `list filters by conversationId`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c2", "b", t0 + 1.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 2.seconds))
assertEquals(2, store.list("c1", Instant.DISTANT_PAST, 0, 100).size)
assertEquals(1, store.list("c2", Instant.DISTANT_PAST, 0, 100).size)
}
@Test
fun `list filters by after cursor`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
val afterT0 = store.list("c1", t0, 0, 100)
assertEquals(listOf("m2", "m3"), afterT0.map { it.id })
}
@Test
fun `list applies offset and limit`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
repeat(10) { i -> store.append(userMsg("m$i", "c1", "x", t0 + i.seconds)) }
val page = store.list("c1", Instant.DISTANT_PAST, offset = 3, limit = 4)
assertEquals(listOf("m3", "m4", "m5", "m6"), page.map { it.id })
}
@Test
fun `clear empties the cache`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "x", t0))
assertEquals(1, store.size())
store.clear()
assertEquals(0, store.size())
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
}
}
+35
View File
@@ -0,0 +1,35 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// KMP-реализация :journal-api (JournalStore / MutableJournalStore) поверх ksqlite.
// Минимальная — только таблица `message` для append-only audit log'а.
// ConversationStore / ReflectionStore / WorkingMemoryStore живут в своих
// собственных ksqlite-модулях.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite).
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.serialization.json)
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,117 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
* ksqlite-реализация [MutableJournalStore] (append-only audit log).
*
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore]
* из `:storage-ksqlite`, но:
* - лежит в собственном модуле `:journal-ksqlite`;
* - реализует переименованный [MutableJournalStore] (раньше был
* `MutableMessageStore`, теперь главный класс — `JournalStore` /
* `MutableJournalStore`); сам тип записи [MessageRecord] не
* переименовывался.
*
* Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]).
*
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
* отдельно.
*/
class KsqliteJournalStore internal constructor(
private val connection: SQLiteConnection,
) : MutableJournalStore {
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true }
private val insertStmt: SQLitePreparedStatement = connection.prepare(
"""
INSERT INTO ${Schema.TABLE_MESSAGE}
(${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val listStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT ${Schema.COL_ID}, ${Schema.COL_CONVERSATION_ID}, ${Schema.COL_KIND},
${Schema.COL_PAYLOAD_JSON}, ${Schema.COL_CREATED_AT}
FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
ORDER BY ${Schema.COL_CREATED_AT} ASC, ${Schema.COL_ID} ASC
LIMIT ? OFFSET ?
""".trimIndent()
)
private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record)
mutex.withLock {
insertStmt.reset()
insertStmt.clearBindings()
insertStmt.bindText(1, record.id)
insertStmt.bindText(2, record.conversationId)
insertStmt.bindText(3, kind)
insertStmt.bindText(4, payload)
insertStmt.bindLong(5, record.createdAt.toEpochMilliseconds())
insertStmt.executeUpdate()
}
}
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = withContext(Dispatchers.Default) {
mutex.withLock {
listStmt.reset()
listStmt.clearBindings()
listStmt.bindText(1, conversationId)
listStmt.bindLong(2, after.toEpochMilliseconds())
listStmt.bindLong(3, limit.toLong())
listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json))
}
out
}
}
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock {
clearStmt.reset()
clearStmt.clearBindings()
clearStmt.bindText(1, conversationId)
clearStmt.executeUpdate()
}
}
override fun close() {
insertStmt.close()
listStmt.close()
clearStmt.close()
}
}
@@ -0,0 +1,79 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.decodeBodyPayload
import pw.binom.agentik.journal.encodeBodyPayload
import pw.binom.db.ksqlite.SQLiteResultSet
import kotlin.time.Instant
/**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
*
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
*/
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
content = record.content,
context = record.context,
)
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(
content = record.content,
tokens = record.tokens,
)
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(),
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
)
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
ResultPayload.serializer(),
ResultPayload(toolCallId = record.toolCallId, 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, 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)
@kotlinx.serialization.Serializable
internal data class ResultPayload(val toolCallId: String, val result: String?)
@kotlinx.serialization.Serializable
internal data class ErrorPayload(val message: String, val code: String?)
@@ -0,0 +1,91 @@
package pw.binom.agentik.journal.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
*
* Минимум — только то, что относится к `message` (append-only audit log).
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`)
* живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
const val TABLE_MESSAGE = "message"
// ───── Колонки ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ─────
const val IDX_MSG_CONV = "idx_msg_conv"
private val v1Ddl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL,
$COL_KIND TEXT NOT NULL,
$COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL
);
""".trimIndent()
private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
""".trimIndent()
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм,
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция —
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
*/
fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
}
@@ -0,0 +1,106 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.journal.TurnTokens
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* Тесты для [KsqliteJournalStore] — точная копия
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory
* SQLiteConnection + Schema.migrate).
*
* Тест `testClearRemovesByConversation` из оригинала использовал
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
* автономен и не знает про ConversationStore.
*/
class KsqliteJournalStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteJournalStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteJournalStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
@Test
fun testAppendUserAndRetrieve() = runTest {
store.append(MessageRecord.UserMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hello")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
context = null,
))
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0]
assertEquals("m1", msg.id)
assertEquals(MessageRecord.UserMessage::class, msg::class)
}
@Test
fun testAppendAssistantWithTokens() = runTest {
store.append(MessageRecord.AssistantMessage(
id = "m1",
conversationId = "conv1",
content = listOf(Content.Text("hi")),
createdAt = Instant.parse("2026-09-15T10:01:00Z"),
tokens = TurnTokens(input = 50, output = 30),
))
val list = store.listFlow("conv1", Instant.DISTANT_PAST).toList()
assertEquals(1, list.size)
val msg = list[0] as MessageRecord.AssistantMessage
assertEquals(TurnTokens(input = 50, output = 30), msg.tokens)
}
@Test
fun testListAfterFiltersByTimestamp() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
val after = store.list("conv1", after = t1, offset = 0, limit = 10)
assertEquals(2, after.size)
assertEquals(listOf("m2", "m3"), after.map { it.id })
}
@Test
fun testListFlowReturnsAllInOrder() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..3) store.append(
MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t + kotlin.time.Duration.parse("PT${i}S"), null)
)
assertEquals(listOf("m1", "m2", "m3"), store.listFlow("conv1", Instant.DISTANT_PAST).toList().map { it.id })
}
@Test
fun testClearRemovesByConversation() = runTest {
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), Instant.parse("2026-09-15T10:00:00Z"), null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), Instant.parse("2026-09-15T10:00:00Z"), null))
store.clear("conv1")
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
}
}
+39
View File
@@ -0,0 +1,39 @@
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
//
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
// доступные JVM target'у.
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
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)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
}
jvmMain.dependencies {
// mu.KotlinLogging — JVM-only, для SkillMiner'а
implementation(libs.kotlin.logging)
}
}
}
@@ -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.LiteContentPart
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -10,7 +10,7 @@ import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.NewMemoryNote import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.storage.Ids import pw.binom.agentik.journal.Ids
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -1,12 +1,12 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import pw.binom.agentik.storage.Ids import pw.binom.agentik.journal.Ids
import pw.binom.agentik.storage.Reflection import pw.binom.agentik.reflection.Reflection
import kotlin.time.Clock import kotlin.time.Clock
/** /**
@@ -27,7 +27,7 @@ import kotlin.time.Clock
*/ */
class LlmReflector( class LlmReflector(
private val llm: LiteLlm, private val llm: LiteLlm,
private val maxTurns: Int = 6, val maxTurns: Int = 6,
private val maxTokens: Int = 512, private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
@@ -57,7 +57,7 @@ class LlmReflector(
val parsed = ReflectionParser.parse(raw) val parsed = ReflectionParser.parse(raw)
?: return@withContext null ?: return@withContext null
Reflection( Reflection(
id = Ids.reflection(), id = pw.binom.agentik.reflection.Ids.new(),
conversationId = null, // будет проставлен caller'ом ChatConversation conversationId = null, // будет проставлен caller'ом ChatConversation
createdAt = clock.now(), createdAt = clock.now(),
turnsAnalyzed = turns.size, turnsAnalyzed = turns.size,
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
/** /**
* Минимальный парсер JSON-ответа от [LlmReflector]. * Минимальный парсер JSON-ответа от [LlmReflector].
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision import pw.binom.agentik.memory.MemoryReviewDecision
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
+39
View File
@@ -0,0 +1,39 @@
// Generic MCP (Model Context Protocol) bridge — переиспользуемый модуль,
// который превращает любой MCP-сервер (stdio subprocess или HTTP endpoint)
// в набор [LiteTool]-адаптеров.
//
// Вынесен из :standalone — MCP не специфичен для standalone'а, это generic
// мост между MCP-SDK и litert-kmp. Может переиспользоваться в :agentik-cli
// или :agentik-tui когда те снова включатся.
//
// Зависимости:
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
// - litert.api для LiteTool контракта
// - MCP SDK (JVM-only)
// - Ktor client (для StreamableHttpClientTransport)
// - kotlinx-serialization для парсинга конфига
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
}
dependencies {
implementation(project(":agent-toolsets"))
api(libs.litert.api)
implementation(libs.mcp.sdk.client)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlin.logging)
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.mcp package pw.binom.agentik.mcp.bridge
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.mcp package pw.binom.agentik.mcp.bridge
import mu.KotlinLogging import mu.KotlinLogging
@@ -30,9 +30,9 @@ import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Реестр подключённых MCP-серверов. * Реестр подключённых MCP-серверов.
+7
View File
@@ -21,6 +21,13 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
// `TextEmbeddingExecutor` (suspend-обёртка над `TextEmbeddingExtractor`)
// живёт в :memory-api с 2026-09-21 — раньше был `EmbeddingProvider` в
// :memory-vector, но он JVM-only и блокировал :memory-md-vector от
// нативных таргетов. text-embedding-kmp:api собирается под jvm+android+
// linux/macos/ios/mingw (мы добавили нативные цели в их :api модуле),
// так что KMP-потребители могут зависеть от него напрямую.
api(libs.text.embedding.api)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
@@ -24,4 +24,11 @@ data class MemoryNote(
val useCount: Int = 0, val useCount: Int = 0,
val conversationId: String? = null, val conversationId: String? = null,
val source: MemorySource, val source: MemorySource,
) ) {
/**
* Дешёвый content-fingerprint: хэш от id + content.
* Используется vector-кэшами (`:memory-md-vector`, `:memory-vector`) для
* определения "изменилась ли заметка" без re-embed'а.
*/
fun contentHash(): String = (id.hashCode().toLong() xor content.hashCode().toLong()).toString(16)
}
@@ -0,0 +1,58 @@
package pw.binom.agentik.memory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
/**
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score
* в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
* `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит
* от JVM-only `:memory-vector`.
*/
data class ScoredVector(
val id: String,
val score: Float,
)
/**
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
* векторов к query. Метаданные заметок лежат в `MemoryStore` (для
* vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`);
* индекс хранит только embedding'и + id-маппинг.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
* `:memory-md-vector` (KMP).
*
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации —
* это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
*/
interface MemoryVectorIndex : AutoCloseable {
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
val dimension: Int
/** Количество записей в индексе. */
suspend fun size(): Long
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
suspend fun add(id: String, embedding: FloatArray)
/** Удалить запись по [id]. Возвращает true если запись была. */
suspend fun remove(id: String): Boolean
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id. */
suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean = { true },
): List<ScoredVector>
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
suspend fun flush()
override fun close()
}
@@ -0,0 +1,22 @@
package pw.binom.agentik.memory
/**
* Доп. контекст для vector-индекса: фильтр по категории и conversationId.
*
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с
* переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён
* сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`.
*
* Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые
* будущие) должны использовать этот хелпер при фильтрации результатов search,
* чтобы контракт был единый.
*/
fun noteMatches(
note: MemoryNote,
category: MemoryCategory? = null,
conversationId: String? = null,
): Boolean {
if (category != null && note.category != category) return false
if (conversationId != null && note.conversationId != conversationId) return false
return true
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.memory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/**
* Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`.
*
* `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP),
* поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают
* честный suspend, а блокирующая работа уходит в background dispatcher.
*
* Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI
* text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем
* его; иначе — определяем лениво по первому [embed] (probe-vector на пустом
* тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования,
* так что для prod-использования рекомендуется всегда передавать [knownDimension]
* явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а).
*
* @param extractor underlying extractor (не null)
* @param knownDimension заранее известная размерность; null = определить по probe
*/
class TextEmbeddingExecutor(
val extractor: TextEmbeddingExtractor,
val knownDimension: Int? = null,
) : AutoCloseable {
/** Размерность векторов. Эффективно константа после первого обращения. */
val dimension: Int by lazy {
knownDimension ?: extractor.embed("").dim
}
/**
* Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит
* в [Dispatchers.Default] — caller может безопасно await'ить.
*/
suspend fun embed(text: String): FloatArray =
withContext(Dispatchers.Default) { extractor.embed(text).values }
/** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
texts.map { embed(it) }
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
override fun close() {
extractor.close()
}
}
+51
View File
@@ -0,0 +1,51 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// :memory-md-vector — гибридное хранилище памяти:
//
// .md файлы (:memory-md, single source of truth)
// ↓ reconcile() на старте
// sqlite vector index (ksqlite + sqlite-vec vec0, derived cache)
//
// `.md` — единственный источник правды по метаданным и тексту заметок.
// Вектора — derived cache, перестраивается на старте и при `upsert`/`delete`.
//
// ANN-поиск: vector KNN (sqlite-vec MATCH) → top-50 → keyword rerank
// через `MdMemoryFormat.keywordScore` (vector 0.7 + keyword 0.3).
//
// Цели сборки — KMP: jvm() + linuxX64() + mingwX64(). До 2026-09-21 был
// JVM-only, потому что тащил `EmbeddingProvider` из JVM-only `:memory-vector`.
// С переходом на `TextEmbeddingExecutor` (из `:memory-api`, который тянет
// `pw.binom.ai.embeddingtext:api` — теперь KMP) модуль стал платформо-
// независимым. Под нативом тесты работают с `FakeTextEmbeddingExtractor`;
// прод-реализация (`:siglip` модуль text-embedding-kmp) пока JVM+Android only.
kotlin {
jvmToolchain(21)
jvm()
linuxX64()
mingwX64()
sourceSets {
commonMain.dependencies {
// ksqlite 0.1.2 опубликован в Maven Central — обычный
// `mavenCentral()` в settings.gradle.kts его подтянет.
implementation("pw.binom.db:ksqlite:0.1.2")
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.io.core)
api(project(":memory-api"))
implementation(project(":memory-md"))
// `text-embedding-api` тянется транзитивно через `:memory-api`
// (мы добавили `api(libs.text.embedding.api)` в memory-api/build.gradle.kts).
// Раньше тут стоял `implementation(project(":memory-vector"))` ради
// `EmbeddingProvider` — JVM-only модуль с JVector. Теперь не нужен.
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,206 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.md.MdMemoryFormat
import pw.binom.agentik.memory.md.MdMemoryStore
import pw.binom.agentik.memory.noteMatches
import kotlin.time.Instant
import kotlinx.coroutines.flow.Flow
/**
* Гибридное хранилище памяти:
*
* * `.md` файлы (через [MdMemoryStore]) — single source of truth по
* метаданным и тексту заметок;
* * ksqlite vector index ([KsqliteVectorIndex]) — derived cache embeddings
* и content_hash.
*
** Архитектурный контракт:
*
* 1. Любая мутация (upsert/delete) обновляет оба слоя атомарно: сначала
* `.md` (через [MdMemoryStore]), потом векторный кэш. Если vector-write
* упал — `.md` уже сохранён; reconcile при следующем старте восстановит
* консистентность.
*
* 2. [search] использует vector ANN (sqlite-vec MATCH) → top-50 → keyword
* rerank (`MdMemoryFormat.keywordScore`). Финальный score = 0.7 * vector
* + 0.3 * keyword. Это даёт семантический recall с быстрой фильтрацией
* по точным совпадениям.
*
* 3. [reconcile] — вызывается при старте (из [openHybridMemoryStore]):
* - .md файл есть, вектора нет → embed + add;
* - .md файл есть, вектор есть, content_hash отличается → re-embed;
* - .md файла нет, вектор есть → orphan, remove.
*
* 4. Read-only методы ([get], [list], [markUsed], [archiveStale], [events])
* делегируются в [MdMemoryStore] напрямую — никакой транзакции с
* vector-кэшем.
*
* Потокобезопасность: делегирующие методы — thread-safe за счёт
* `MdMemoryStore.mu`. Мутации векторов сериализуются
* [KsqliteVectorIndex.mutex]. Метод [reconcile] держит свой [mutex] для
* исключения конкурентных upsert'ов во время согласования.
*/
class HybridMdVectorStore internal constructor(
private val mdStore: MdMemoryStore,
private val vectorIndex: KsqliteVectorIndex,
private val embedder: TextEmbeddingExecutor,
) : MemoryStore {
private val reconcileMutex = Mutex()
/**
* Отчёт о согласовании `.md` ↔ vector-индекс. Возвращается из [reconcile].
*/
data class ReconcileReport(
val added: Int,
val reembedded: Int,
val orphansRemoved: Int,
) {
val totalChanged: Int get() = added + reembedded + orphansRemoved
}
/**
* Согласовать vector-кэш с текущим состоянием `.md` файлов.
*
* Идемпотентен — повторный вызов no-op.
*
* Можно вызывать из фонового потока при старте `Main.kt` чтобы
* залогировать "reconciled: 5 re-embedded, 2 added, 0 orphans".
*/
suspend fun reconcile(): ReconcileReport = reconcileMutex.withLock {
val onDisk: List<MemoryNote> = mdStore.list(limit = Int.MAX_VALUE)
val onDiskById: Map<String, MemoryNote> = onDisk.associateBy { it.id }
val inCache: List<KsqliteVectorIndex.MetaEntry> = vectorIndex.allMeta()
val cachedIds: Set<String> = inCache.map { it.id }.toSet()
var added = 0
var reembedded = 0
var orphansRemoved = 0
// 1) orphan-cleanup: vector есть, .md нет
for (cached in inCache) {
if (cached.id !in onDiskById) {
vectorIndex.remove(cached.id)
orphansRemoved++
}
}
// 2) re-embed / add
for (note in onDisk) {
val cached = inCache.firstOrNull { it.id == note.id }
val currentHash = note.contentHash()
if (cached == null) {
// .md есть, вектора нет → add
val vec = embedder.embed(note.content)
vectorIndex.add(note.id, vec, currentHash)
added++
} else if (cached.contentHash != currentHash) {
// .md изменился → re-embed
val vec = embedder.embed(note.content)
vectorIndex.add(note.id, vec, currentHash)
reembedded++
}
// else: cached.contentHash == currentHash → no-op
}
ReconcileReport(added, reembedded, orphansRemoved)
}
// ─── MemoryStore impl: мутации ─────────────────────────────────────
override suspend fun upsert(note: MemoryNote) {
mdStore.upsert(note)
val vec = embedder.embed(note.content)
vectorIndex.add(note.id, vec, note.contentHash())
}
override suspend fun delete(id: String): Boolean {
val existed = mdStore.delete(id)
vectorIndex.remove(id)
return existed
}
// ─── MemoryStore impl: search (hybrid) ────────────────────────────
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
if (query.query.isBlank()) return emptyList()
if (query.topK <= 0) return emptyList()
// Этап 1: vector ANN top-K (K=50 или больше topK).
val candidateK = maxOf(query.topK, VECTOR_CANDIDATES)
val qVec = embedder.embed(query.query)
val vectorHits = vectorIndex.search(qVec, candidateK)
// Этап 2: загружаем кандидатов из .md (single source of truth)
// mapNotNull не умеет suspend, поэтому собираем вручную.
val candidates: List<Pair<MemoryNote, Float>> = buildList(vectorHits.size) {
for (hit in vectorHits) {
val note = mdStore.get(hit.id) ?: continue
// Применяем категорийный/конво-фильтр ДО rerank — экономим keywordScore.
if (!noteMatches(note, query.category, query.conversationId)) continue
add(note to hit.score)
}
}
// Этап 3: keyword rerank (vector 0.7 + keyword 0.3)
val rescored = candidates.map { (note, vecScore) ->
val kwScore = MdMemoryFormat.keywordScore(query.query, note)
val finalScore = vecScore * VECTOR_WEIGHT + kwScore * KEYWORD_WEIGHT
MemorySearchResult(note, finalScore)
}.sortedByDescending { it.score }
return if (rescored.size > query.topK) rescored.subList(0, query.topK) else rescored
}
// ─── MemoryStore impl: read-only delegation ───────────────────────
override suspend fun get(id: String): MemoryNote? = mdStore.get(id)
override suspend fun list(
category: pw.binom.agentik.memory.MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = mdStore.list(category, conversationId, limit, offset)
override suspend fun markUsed(id: String, at: Instant) = mdStore.markUsed(id, at)
override suspend fun archiveStale(
maxAge: kotlin.time.Duration,
maxUseCount: Int,
now: Instant,
): Int {
// Вектор-кэш не хранит lastUsedAt/useCount (только content_hash).
// Делегируем в mdStore — он сам знает что удалять; vector удалится
// каскадно при archiveStale → delete loop ниже.
val deleted = mdStore.archiveStale(maxAge, maxUseCount, now)
// Дополнительно чистим vector-кэш от записей, которых больше нет в .md
val remaining = mdStore.list(limit = Int.MAX_VALUE).map { it.id }.toSet()
vectorIndex.allMeta().forEach { entry ->
if (entry.id !in remaining) vectorIndex.remove(entry.id)
}
return deleted
}
override fun events(): Flow<MemoryStoreEvent> = mdStore.events()
override fun close() {
runCatching { vectorIndex.close() }
runCatching { mdStore.close() }
}
companion object {
const val VECTOR_WEIGHT: Float = 0.7f
const val KEYWORD_WEIGHT: Float = 0.3f
const val VECTOR_CANDIDATES: Int = 50
}
}
@@ -0,0 +1,66 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.io.files.Path
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.md.openMdMemory
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Открыть гибридное хранилище памяти (`.md` + sqlite vector index).
*
* Создаёт:
* - [MdMemoryStore] на [memoryRoot] (`.md` файлы);
* - [KsqliteVectorIndex] на [vectorDbPath] (sqlite-vec vec0);
* - [HybridMdVectorStore] — обёртка с reconcile и hybrid search.
*
* Перед возвратом выполняет [HybridMdVectorStore.reconcile] — для свежей
* БД это приведёт к первичному embed'у всех `.md` файлов; для существующей —
* к re-embed'у изменившихся заметок и orphan-cleanup.
*
* @param memoryRoot директория с `.md` файлами (`USER.md`, `WORLD.md`, ...).
* @param vectorDbPath путь к файлу sqlite-БД для vector-кэша.
* @param dimension размерность embeddings от [embedder]. Фиксируется
* при создании индекса; дальнейшая смена = wipe БД.
* @param embedder провайдер embeddings.
* @param runReconcile выполнить [HybridMdVectorStore.reconcile] сразу после
* открытия. В тестах можно отключить для скорости.
*/
fun openHybridMemoryStore(
memoryRoot: Path,
vectorDbPath: Path,
dimension: Int,
embedder: TextEmbeddingExecutor,
runReconcile: Boolean = true,
): HybridMdVectorStore {
val md = openMdMemory(memoryRoot)
val conn = SQLiteConnection.open(vectorDbPath.toString())
Schema.migrate(conn, dimension)
val idx = KsqliteVectorIndex(conn, dimension)
val hybrid = HybridMdVectorStore(md, idx, embedder)
if (runReconcile) {
kotlinx.coroutines.runBlocking { hybrid.reconcile() }
}
return hybrid
}
/**
* In-memory вариант для тестов: vector-кэш в `:memory:` sqlite,
* `.md` — в `/tmp/agentik-hybrid-test-{random}`.
*
* Используется POSIX-путь `/tmp`, потому что [System.getenv] / [System.getProperty]
* недоступны в KMP commonMain (только JVM). На Windows mingwX64 этот вызов
* упадёт — там тесты пока не предполагаются, нативные тесты только linuxX64.
* Под JVM `/tmp` либо есть как symlink (Linux/macOS), либо стоит использовать
* jvmTest-специфичный factory.
*/
fun openInMemoryHybridMemoryStore(
dimension: Int,
embedder: TextEmbeddingExecutor,
): HybridMdVectorStore {
val tmpDir = Path("/tmp/agentik-hybrid-test-${kotlin.random.Random.nextLong()}")
val md = openMdMemory(tmpDir)
val conn = SQLiteConnection.memory("hybrid-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn, dimension)
val idx = KsqliteVectorIndex(conn, dimension)
return HybridMdVectorStore(md, idx, embedder)
}
@@ -0,0 +1,47 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.io.files.Path
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.md.KeywordMdPrefetcher
import pw.binom.agentik.memory.md.KeywordMdReviewer
/**
* Связка [HybridMdVectorStore] + keyword-prefetcher + keyword-reviewer.
*
* Store делегирует I/O между .md (single source of truth) и sqlite-vector-кэшем;
* prefetcher и reviewer работают по .md-данным (через [HybridMdVectorStore]),
* так что обе роли видят консистентное состояние.
*/
class HybridMemorySystem internal constructor(
override val store: MemoryStore,
override val prefetcher: MemoryPrefetcher,
override val reviewer: MemoryReviewer,
) : MemorySystem {
override fun close() = store.close()
}
/**
* Собирает [HybridMemorySystem] для указанной корневой директории + sqlite-БД.
*
* Под капотом: [HybridMdVectorStore] (md + vector), keyword-prefetcher из
* `:memory-md` (работает по store.api), keyword-reviewer без LLM —
* LLM-импл добавляется в `:standalone` поверх.
*/
fun openHybridMemorySystem(
memoryRoot: Path,
vectorDbPath: Path,
dimension: Int,
embedder: TextEmbeddingExecutor,
runReconcile: Boolean = true,
): HybridMemorySystem {
val store = openHybridMemoryStore(memoryRoot, vectorDbPath, dimension, embedder, runReconcile)
return HybridMemorySystem(
store = store,
prefetcher = KeywordMdPrefetcher(store),
reviewer = KeywordMdReviewer(),
)
}
@@ -0,0 +1,305 @@
package pw.binom.agentik.memory.mdvector
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryVectorIndex
import pw.binom.agentik.memory.ScoredVector
import pw.binom.db.ksqlite.SQLiteConnection
import pw.binom.db.ksqlite.SQLitePreparedStatement
import kotlin.time.Clock
/**
* ksqlite-реализация [MemoryVectorIndex] поверх `vec0` virtual table
* (sqlite-vec extension, встроен в ksqlite).
*
* Маппинг id → rowid:
* - TEXT `id` (== MemoryNote.id, "mem-...") лежит в [Schema.TABLE_META].
* - `vec0` индексирует по `rowid` (INTEGER auto-increment).
* - JOIN через `WHERE vec0.rowid = meta.rowid`.
*
* На каждое [add] с contentHash рядом с вектором пишется meta с
* content_hash от [MemoryNote.contentHash]. Это позволяет reconcile'у
* в [HybridMdVectorStore] определить "изменилась ли заметка" без re-embed.
*
* Поиск — `vec0` MATCH (cosine distance, sqlite-vec native). Score
* конвертируется из distance (0..2, меньше = ближе) в similarity
* (0..1, больше = ближе).
*
* Конкурентность: write-операции сериализуются [mutex]; read'ы
* (`size`/`search`) не блокируют.
*/
class KsqliteVectorIndex internal constructor(
private val conn: SQLiteConnection,
override val dimension: Int,
) : MemoryVectorIndex {
private val mutex = Mutex()
private val insertVec: SQLitePreparedStatement = conn.prepare(
"INSERT INTO ${Schema.TABLE_VECTORS}(${Schema.COL_VECTOR}) VALUES (?)"
)
private val lastInsertRowIdStmt: SQLitePreparedStatement = conn.prepare(
"SELECT last_insert_rowid()"
)
private val insertMeta: SQLitePreparedStatement = conn.prepare(
"""
INSERT OR REPLACE INTO ${Schema.TABLE_META}
(${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH},
${Schema.COL_DIMENSION}, ${Schema.COL_UPDATED_AT})
VALUES (?, ?, ?, ?, ?)
""".trimIndent()
)
private val findMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
)
/** Запрос `... WHERE rowid IN (?, ?, ...)`. Подготавливаем на N=$MAX_INLINE_ROWIDS параметров. */
private val findMetaByRowIdsStmt: SQLitePreparedStatement = conn.prepare(
(1..MAX_INLINE_ROWIDS).joinToString(
separator = ",",
prefix = "SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} IN (",
postfix = ")",
) { "?" }
)
private val deleteByRowIdStmt: SQLitePreparedStatement = conn.prepare(
"DELETE FROM ${Schema.TABLE_VECTORS} WHERE rowid = ?"
)
private val deleteMetaByRowIdStmt: SQLitePreparedStatement = conn.prepare(
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} = ?"
)
private val deleteMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
)
private val sizeMetaStmt: SQLitePreparedStatement = conn.prepare(
"SELECT COUNT(*) FROM ${Schema.TABLE_META}"
)
private val allMetaStmt: SQLitePreparedStatement = conn.prepare(
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META}"
)
/**
* sqlite-vec требует чтобы `LIMIT` в MATCH-запросе был integer-литералом,
* а не `?`. Поэтому для search используем динамическую подготовку
* (кешированную по [k]).
*
* Vec0 KNN check (`sqlite-vec` source): «A LIMIT or 'k = ?' constraint is
* required on vec0 knn queries». Имя параметра `:k` тоже поддерживается,
* но у ksqlite bind API — только позиционный; literal проще.
*/
private val searchCache = HashMap<Int, SQLitePreparedStatement>()
/**
* Выдать rowid для существующей записи или -1 если нет.
*
* **ВАЖНО**: вызывающий ОБЯЗАН держать [mutex]. Этот метод НЕ
* reentrant — повторный вход в [Mutex.withLock] приведёт к
* deadlock (kotlinx.coroutines.sync.Mutex не reentrant).
*/
private fun findRowIdLocked(id: String): Long {
findMetaByIdStmt.reset()
findMetaByIdStmt.clearBindings()
findMetaByIdStmt.bindText(1, id)
findMetaByIdStmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0) ?: -1L
}
return -1L
}
override suspend fun size(): Long = mutex.withLock {
sizeMetaStmt.reset()
sizeMetaStmt.clearBindings()
sizeMetaStmt.executeQuery().use { rs ->
if (rs.next()) rs.getLong(0) ?: 0L else 0L
}
}
override suspend fun add(id: String, embedding: FloatArray) {
require(embedding.size == dimension) {
"embedding dim=${embedding.size} != index dim=$dimension"
}
add(id, embedding, contentHash = "")
}
/**
* Добавить или обновить запись с явным content_hash.
* Если запись с таким id уже есть — обновляет и вектор, и meta.
* Иначе — создаёт новый rowid.
*/
suspend fun add(id: String, embedding: FloatArray, contentHash: String) {
require(embedding.size == dimension) {
"embedding dim=${embedding.size} != index dim=$dimension"
}
mutex.withLock {
val existingRowId = findRowIdLocked(id)
val rowId: Long = if (existingRowId > 0) {
// Update: заменяем вектор по существующему rowid
insertVec.reset()
insertVec.clearBindings()
insertVec.bindVector(1, embedding)
insertVec.executeUpdate()
existingRowId
} else {
// Insert: получаем свежий rowid
insertVec.reset()
insertVec.clearBindings()
insertVec.bindVector(1, embedding)
insertVec.executeUpdate()
lastInsertRowIdStmt.reset()
lastInsertRowIdStmt.clearBindings()
lastInsertRowIdStmt.executeQuery().use { rs ->
if (rs.next()) rs.getLong(0) ?: error("no last_insert_rowid()") else error("no last_insert_rowid()")
}
}
insertMeta.reset()
insertMeta.clearBindings()
insertMeta.bindLong(1, rowId)
insertMeta.bindText(2, id)
insertMeta.bindText(3, contentHash)
insertMeta.bindLong(4, dimension.toLong())
insertMeta.bindLong(5, Clock.System.now().toEpochMilliseconds())
insertMeta.executeUpdate()
}
}
override suspend fun remove(id: String): Boolean = mutex.withLock {
val rowId = findRowIdLocked(id)
if (rowId <= 0) return@withLock false
// Удаляем meta сначала — иначе orphan-row в vec0.
deleteMetaByIdStmt.reset()
deleteMetaByIdStmt.clearBindings()
deleteMetaByIdStmt.bindText(1, id)
deleteMetaByIdStmt.executeUpdate()
deleteByRowIdStmt.reset()
deleteByRowIdStmt.clearBindings()
deleteByRowIdStmt.bindLong(1, rowId)
deleteByRowIdStmt.executeUpdate()
true
}
/** Прочитать content_hash для id. null если записи нет. */
suspend fun getContentHash(id: String): String? = mutex.withLock {
findMetaByIdStmt.reset()
findMetaByIdStmt.clearBindings()
findMetaByIdStmt.bindText(1, id)
findMetaByIdStmt.executeQuery().use { rs ->
if (rs.next()) rs.getText(1) else null
}
}
/** Полный список (rowid, id, contentHash) для reconcile'а. */
suspend fun allMeta(): List<MetaEntry> = mutex.withLock {
allMetaStmt.reset()
allMetaStmt.clearBindings()
val out = mutableListOf<MetaEntry>()
allMetaStmt.executeQuery().use { rs ->
while (rs.next()) {
val rowId = rs.getLong(0) ?: continue
val id = rs.getText(1) ?: continue
val hash = rs.getText(2) ?: continue
out.add(MetaEntry(rowId, id, hash))
}
}
out
}
override suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean,
): List<ScoredVector> {
require(query.size == dimension) {
"query dim=${query.size} != index dim=$dimension"
}
val safeK = k.coerceAtLeast(1)
return mutex.withLock {
// sqlite-vec MATCH требует минимальный запрос без JOIN/лишних
// ORDER BY — иначе "A LIMIT or 'k = ?' constraint is required".
// Поэтому делаем два запроса:
// 1) vec0 ANN → (rowid, distance)
// 2) meta lookup по собранным rowid → id
val stmt = searchCache.getOrPut(safeK) {
conn.prepare(
"""
SELECT rowid, distance
FROM ${Schema.TABLE_VECTORS}
WHERE ${Schema.COL_VECTOR} MATCH ?
ORDER BY distance
LIMIT $safeK
""".trimIndent()
)
}
stmt.reset()
stmt.clearBindings()
stmt.bindVector(1, query)
val candidates = mutableListOf<Pair<Long, Float>>()
stmt.executeQuery().use { rs ->
while (rs.next()) {
val rowId = rs.getLong(0) ?: continue
val distance = rs.getDouble(1) ?: continue
val score = ((1.0 - distance / 2.0) * 1.0).toFloat().coerceIn(0f, 1f)
candidates.add(rowId to score)
}
}
if (candidates.isEmpty()) return@withLock emptyList<ScoredVector>()
// 2-й запрос: meta по списку rowid.
val rowIds = candidates.map { it.first }
val idByRowId = HashMap<Long, String>(candidates.size)
findMetaByRowIdsStmt.reset()
findMetaByRowIdsStmt.clearBindings()
for ((idx, rowId) in rowIds.withIndex()) {
findMetaByRowIdsStmt.bindLong(idx + 1, rowId)
}
findMetaByRowIdsStmt.executeQuery().use { rs ->
while (rs.next()) {
val rowId = rs.getLong(0) ?: continue
val id = rs.getText(1) ?: continue
idByRowId[rowId] = id
}
}
candidates.mapNotNull { (rowId, score) ->
idByRowId[rowId]?.let { ScoredVector(it, score) }
}
}
}
override suspend fun flush() {
// ksqlite + WAL — flush не нужен. Метод для совместимости с интерфейсом.
}
override fun close() {
insertVec.close()
lastInsertRowIdStmt.close()
insertMeta.close()
findMetaByIdStmt.close()
deleteByRowIdStmt.close()
deleteMetaByRowIdStmt.close()
deleteMetaByIdStmt.close()
sizeMetaStmt.close()
allMetaStmt.close()
searchCache.values.forEach { it.close() }
}
data class MetaEntry(val rowId: Long, val id: String, val contentHash: String)
private companion object {
/** Максимум rowid, которые мы зашиваем в `IN (?,?,...)` одним prepared statement'ом. */
const val MAX_INLINE_ROWIDS = 256
}
}
@@ -0,0 +1,80 @@
package pw.binom.agentik.memory.mdvector
import pw.binom.db.ksqlite.SQLiteConnection
/**
* DDL/DML для ksqlite-бэкенда `:memory-md-vector`.
*
* Две таблицы:
*
* * `memory_vectors` (vec0) — ANN-индекс. Содержит embedding + первичный
* ключ `rowid` (auto-increment INTEGER, sqlite-vec требует именно его).
* Размерность задаётся `float[DIMENSION]` при создании.
*
* * `memory_meta` — рядом с вектором: TEXT `id` (== MemoryNote.id) +
* `rowid` (тот же, что в vec0) + `content_hash` (от MemoryNote.contentHash()).
* Используется reconcile'ом — если хэш в meta не совпадает с тем, что
* вычисляется из текущего `.md` файла → re-embed.
*
* JOIN между vec0 и meta: `WHERE vec0.rowid = meta.rowid`.
*
* Миграция через `PRAGMA user_version` (как в `:journal-ksqlite/Schema.kt`).
*/
internal object Schema {
const val CURRENT_VERSION: Int = 1
const val TABLE_VECTORS = "memory_vectors"
const val TABLE_META = "memory_meta"
const val COL_ID = "id"
const val COL_ROWID = "rowid"
const val COL_VECTOR = "embedding"
const val COL_HASH = "content_hash"
const val COL_DIMENSION = "dimension"
const val COL_UPDATED_AT = "updated_at"
fun v1Ddl(dimension: Int): String = """
CREATE VIRTUAL TABLE IF NOT EXISTS $TABLE_VECTORS USING vec0(
$COL_VECTOR float[$dimension]
);
CREATE TABLE IF NOT EXISTS $TABLE_META (
$COL_ROWID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
$COL_ID TEXT NOT NULL UNIQUE,
$COL_HASH TEXT NOT NULL,
$COL_DIMENSION INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meta_id ON $TABLE_META($COL_ID);
""".trimIndent()
fun migrate(conn: SQLiteConnection, dimension: Int) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN")
try {
if (current < 1) {
conn.exec(v1Ddl(dimension))
}
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
conn.exec("PRAGMA user_version = $version")
}
}
@@ -0,0 +1,166 @@
package pw.binom.agentik.memory.mdvector
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking as kRunBlocking
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.voice.embeddingtext.TextEmbedding
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/**
* Тесты гибридного стора: делегирование в .md, vector ANN, reconcile,
* hybrid search rerank.
*
* Используется [kRunBlocking] (а не `runTest`) потому что `MdMemoryStore`
* делает реальный файловый I/O (`kotlinx-io`), который плохо дружит с
* TestDispatcher'ом — `runTest` зависает на virtual-time I/O.
*/
class HybridMdVectorStoreTest {
/**
* Детерминированный [TextEmbeddingExtractor] для тестов модуля.
* Хеширует текст в псевдо-вектор фиксированной размерности, L2-normalize.
* Заворачивается в [TextEmbeddingExecutor] с пред-объявленной размерностью.
*/
private fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor =
TextEmbeddingExecutor(FakeTestExtractor(dimension), knownDimension = dimension)
private class FakeTestExtractor(val dim: Int) : TextEmbeddingExtractor {
override fun embed(text: String): TextEmbedding {
val v = FloatArray(dim)
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
for (i in 0 until dim) {
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
var norm = 0f
for (x in v) norm += x * x
norm = kotlin.math.sqrt(norm)
if (norm > 0f) for (i in v.indices) v[i] /= norm
return TextEmbedding(v)
}
override fun close() = Unit
}
private fun note(
id: String,
content: String,
category: MemoryCategory = MemoryCategory.USER,
source: MemorySource = MemorySource.USER_EXPLICIT,
) = MemoryNote(
id = id,
category = category,
content = content,
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
lastUsedAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
useCount = 0,
conversationId = null,
source = source,
)
@Test
fun upsertWritesToBothMdAndVectorCache() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-1", "user prefers dark mode"))
val fromMd = store.get("mem-1")
assertNotNull(fromMd)
assertEquals("user prefers dark mode", fromMd.content)
val hits = store.search(MemorySearchQuery(query = "user prefers dark mode", topK = 5))
assertEquals(1, hits.size)
assertEquals("mem-1", hits.first().note.id)
store.close()
}
@Test
fun deleteRemovesFromBothLayers() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-2", "lives in Saint Petersburg"))
store.upsert(note("mem-3", "loves Kotlin multiplatform"))
assertEquals(2, store.list(limit = 10).size)
val removed = store.delete("mem-2")
assertTrue(removed)
assertEquals(1, store.list(limit = 10).size)
assertNull(store.get("mem-2"))
val hits = store.search(MemorySearchQuery(query = "Saint Petersburg", topK = 5))
assertTrue(hits.isEmpty() || hits.all { it.note.id != "mem-2" })
store.close()
}
@Test
fun reconcileOnEmptyStoreIsNoOp() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
val report = store.reconcile()
assertEquals(0, report.added)
assertEquals(0, report.reembedded)
assertEquals(0, report.orphansRemoved)
store.close()
}
@Test
fun reconcileIsIdempotent() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-10", "works at Binom"))
val report = store.reconcile()
assertEquals(0, report.totalChanged, "идемпотентность reconcile: повторный вызов no-op")
store.close()
}
@Test
fun searchReturnsRelevantResultsByKeyword() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-a", "kotlin multiplatform"))
store.upsert(note("mem-b", "java enterprise"))
store.upsert(note("mem-c", "kotlin coroutines"))
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 5))
val ids = hits.map { it.note.id }.toSet()
assertTrue("mem-a" in ids, "expected 'kotlin multiplatform' in results: $ids")
assertTrue("mem-c" in ids, "expected 'kotlin coroutines' in results: $ids")
store.close()
}
@Test
fun searchRespectsCategoryFilter() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("user-1", "kotlin lover", MemoryCategory.USER))
store.upsert(note("world-1", "kotlin 2.0 released", MemoryCategory.WORLD))
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 10, category = MemoryCategory.USER))
assertEquals(1, hits.size)
assertEquals("user-1", hits.first().note.id)
store.close()
}
@Test
fun hybridScoreCombinesVectorAndKeyword() = kRunBlocking {
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
store.upsert(note("mem-x", "kotlin multiplatform project"))
val hits = store.search(MemorySearchQuery(query = "kotlin multiplatform", topK = 1))
assertEquals(1, hits.size)
val score = hits.first().score
assertTrue(score in 0f..1f, "score $score out of range")
assertTrue(score > 0.5f, "expected hybrid score > 0.5, got $score")
store.close()
}
}
+9 -7
View File
@@ -3,8 +3,9 @@ plugins {
} }
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp // CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не // не подключаются. Раньше был нужен потому что text-embedding-kmp был
// опубликован в caffeine, артефакты есть только в локальном ~/.m2. // только в локальном ~/.m2; с 2026-09-21 (v4 в caffeine) можно убрать,
// но оставлен на случай если CI внезапно отвалится от Nexus.
// Использование: // Использование:
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true // ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
// Локальная разработка без флага — зависимости подключаются как обычно. // Локальная разработка без флага — зависимости подключаются как обычно.
@@ -25,6 +26,10 @@ kotlin {
api(project(":memory-api")) api(project(":memory-api"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// `pw.binom.ai.embeddingtext:api` (TextEmbeddingExtractor + TextEmbedding)
// теперь KMP с нативом (linuxX64/mingwX64/macOS/ios); тянем в commonMain.
// Реализации (`siglip`, `http`) — JVM+Android only, см. jvmMain ниже.
api(libs.text.embedding.api)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
@@ -34,14 +39,11 @@ kotlin {
jvmMain.dependencies { jvmMain.dependencies {
implementation(libs.jvector) implementation(libs.jvector)
implementation(libs.sqldelight.sqlite.driver) implementation(libs.sqldelight.sqlite.driver)
// Конкретная реализация TextEmbeddingExtractor поверх ONNX.
implementation(libs.text.embedding.siglip)
} }
jvmTest.dependencies { jvmTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
} }
} }
} }
dependencies {
add("jvmMainApi", libs.text.embedding.api)
add("jvmMainImplementation", libs.text.embedding.siglip)
}

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