12 Commits
9 ... 14

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

jvmTest green (95 tasks).
2026-09-22 02:55:02 +03:00
166 changed files with 5635 additions and 2865 deletions
+7 -6
View File
@@ -18,8 +18,9 @@ agentik/
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...) ├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск) ├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...) ├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
├── storage-inmemory/ in-memory реализация для тестов и Android │ (исторический, см. journal-api / context-api / reflection-api ниже)
├── storage-sqlite/ SQLite реализация для production ├── ~~storage-inmemory/~~ ~~in-memory реализация для тестов и Android~~ — упразднён 2026-09-22
├── ~~storage-sqlite/~~ ~~SQLite реализация для production~~ — упразднён 2026-09-22
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget ├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik ├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17 ├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
@@ -89,9 +90,9 @@ curl http://localhost:8080/health
- [`:memory-api`](memory-api/README.md) — контракт памяти. - [`:memory-api`](memory-api/README.md) — контракт памяти.
- [`:memory-md`](memory-md/README.md) — Hermes-style файл. - [`:memory-md`](memory-md/README.md) — Hermes-style файл.
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector. - [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
- [`:storage-core`](storage-core/README.md) — контракт storage. - [`:storage-core`](storage-core/README.md) — контракт storage (исторический).
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация. - ~~`:storage-inmemory`~~ — упразднён 2026-09-22.
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production. - ~~`:storage-sqlite`~~ — упразднён 2026-09-22.
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер. - [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
## Где смотреть версии ## Где смотреть версии
@@ -124,7 +125,7 @@ SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппирован
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`): Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка - `.gitea/ci.yml` — PR-build, прогон тестов, проверка
shadowjar'ов. shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты - `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу. в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
-37
View File
@@ -1,37 +0,0 @@
Status of message-log-api migration:
DONE:
1. Created :message-log-api module with build.gradle.kts (KMP, jvm + linuxX64 + mingwX64, kotlinx-serialization plugin).
2. Created 5 files in message-log-api/src/commonMain/kotlin/pw/binom/agentik/messageLog/:
- Content.kt (sealed: Text, Image)
- MessageRecord.kt (sealed: UserMessage, AssistantMessage, ToolCall, ToolResult, Error; plus TurnTokens)
- MessageStore.kt (interface, TokenStats, MessageEvent)
- MessageContext.kt (MessageOrigin enum + MessageContext data class)
- Payload.kt (bodyJson, MessageBodyPayload, encode/decodeBodyPayload, BodyDecoded)
3. Added include(":message-log-api") in settings.gradle.kts (right after message-store-api).
4. Added api(project(":message-log-api")) to working-memory-api/build.gradle.kts.
5. Wrote /tmp/rename_imports.py with 12 FQN renames (MessageRecord, MessageStore, Content, MessageContext, MessageOrigin, TurnTokens, TokenStats, MessageEvent, MessageBodyPayload, BodyDecoded, encodeBodyPayload, decodeBodyPayload).
6. Wrote /tmp/run_rename.sh that runs the python script.
PENDING:
- Run /tmp/run_rename.sh to apply the renames across all consumer files.
- Delete the 5 originals from message-store-api/src/commonMain/kotlin/pw/binom/agentik/messageStore/.
- Add api(project(":message-log-api")) to storage-inmemory/sqlite/ksqlite gradle files.
- Verify build compiles (run standalone tests).
Files that need import updates (per search):
- storage-inmemory/src/commonMain/.../InMemoryMessageStore.kt
- storage-inmemory/src/commonTest/.../InMemoryMessageStoreTest.kt
- storage-sqlite/src/jvmMain/.../SqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../KsqliteMessageStore.kt
- storage-ksqlite/src/commonMain/.../MessageCodecs.kt
- storage-ksqlite/src/commonTest/.../KsqliteMessageStoreTest.kt
- standalone/src/jvmMain/.../ToolDispatcher.kt
- standalone/src/jvmMain/.../ConversationLoop.kt
- standalone/src/jvmTest/.../ChatAgentTest.kt
- standalone/src/jvmTest/.../persistence/PersistenceTest.kt
- standalone/src/jvmTest/.../persistence/SqliteStoresMigrationTest.kt
- standalone/src/jvmTest/.../persistence/TokenStatsTest.kt
Tool issue: run_command keeps failing JSON validation (safe_to_run field required).
Workaround needed before continuing the migration.
+42
View File
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :proto — read-only Agent interface, который MutableAgent расширяет.
// Через api(), иначе downstream-impl ChatAgent не сможет
// override suspend-методы Agent.
api(project(":proto"))
// :memory-api — typealias ConversationTurn на memory-api одноимённый
// класс, иначе пер-конво компоненты (skill mining, reflection) не
// смогут передать его в SkillMiner.mine() напрямую.
api(project(":memory-api"))
// :litert-api — отсюда LiteTool, который ToolProvider.getTools()
// возвращает напрямую. До v9 интерфейс не имел поля name, и был
// промежуточный NamedTool(name, LiteTool); после v9 — лишний слой.
api(libs.litert.api)
// SystemPromptProvider.section() и другие нон-suspend сигнатуры пока
// не дёргают корутины; kotlinx-coroutines нужен на будущее (suspend event
// listener) — оставлен как api, чтобы downstream не забывал объявить.
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.agent
/**
* Нашлёпка поверх [MutableAgent].
*
* Компонент сам регистрирует в агенте свои capability-провайдеры
* при [install] и снимает их при [uninstall]. Агент не знает заранее
* ни о структуре компонента, ни о его провайдерах — это просто
* хук для свободной композиции.
*
* Ktor-style API:
* ```
* val agent = ChatAgent(...)
* .install(SkillComponent(store, miner))
* .install(ReflectionComponent(reflectionStore, reflector))
* .install(MemoryComponent(memorySystem))
* ```
*
* Контракт:
* - [install] **синхронен**: компонент добавляет свои провайдеры в
* `agent.systemProviders` / `agent.toolProviders` сразу. Если нужны
* фоновые корутины — компонент запускает их через свой собственный
* [kotlinx.coroutines.CoroutineScope], переданный в конструктор.
* - [uninstall] **синхронен и идемпотентен**: компонент убирает ровно
* те провайдеры, которые добавил. Можно вызвать повторно — без эффекта.
* - Агент гарантирует, что [uninstall] будет вызван (через [MutableAgent.close]
* или явный [MutableAgent.uninstall]) перед завершением хост-процесса.
*/
interface Component {
/**
* Вызывается агентом при [MutableAgent.install].
*
* Типичные действия: добавить [SystemPromptProvider] в
* `agent.systemProviders`, добавить [ToolProvider] в
* `agent.toolProviders`, запустить фоновые джобы через свой scope.
*/
fun install(agent: MutableAgent)
/**
* Вызывается агентом при [MutableAgent.uninstall] или при
* [MutableAgent.close]. Компонент должен убрать ровно те провайдеры,
* которые добавил в [install], и остановить фоновые джобы.
*/
fun uninstall(agent: MutableAgent)
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.agent
/**
* Хук, через который per-conversation компоненты ([SkillMiningComponent],
* рефлексия и т.п.) подключаются к жизненному циклу разговора.
*
* [MutableAgent] при создании/закрытии разговора вызывает
* [attachConversation] / [detachConversation] на каждом компоненте,
* реализующем этот интерфейс. Внутри компонент хранит
* [ConversationHandle] (или контекст вокруг него) и подписывается на
* нужные события.
*
* Компонент без [ConversationAware] остаётся чисто agent-level — он
* не получает per-conversation хуков.
*/
interface ConversationAware {
fun attachConversation(handle: ConversationHandle)
fun detachConversation(handle: ConversationHandle)
}
/**
* Минимальное окно в разговор, которое компонент видит через
* [ConversationAware]. Содержит только то, что нужно большинству
* per-conversation компонентов:
* - идентификатор (для подписки на события),
* - признак временности (для решения "тратить ли ресурсы на mining/reflection"),
* - последние N turns (для LlmReflector / SkillMiner).
*
* Сознательно НЕ даёт доступ к [MutableAgent] или [ChatConversation] —
* чтобы компонент не лез в чужие обязанности.
*/
interface ConversationHandle : AutoCloseable {
val id: String
val isTemporal: Boolean
/** Последние [limit] turns в разговоре, в хронологическом порядке. */
suspend fun recentTurns(limit: Int): List<ConversationTurn>
override fun close()
}
/**
* Минимальная проекция turn'а для компонентов: пара user-message + ответ
* assistant'а. Типо-алиас на [pw.binom.agentik.memory.ConversationTurn], чтобы
* компоненты (skill mining, reflection) могли передавать его напрямую
* в [pw.binom.agentik.llm.tools.SkillMiner.mine] и аналогичные API без
* конвертации.
*/
typealias ConversationTurn = pw.binom.agentik.memory.ConversationTurn
@@ -0,0 +1,78 @@
package pw.binom.agentik.agent
import pw.binom.agentik.proto.Agent
/**
* Настраиваемая версия [Agent]: расширяет публичный contract агента
* install/uninstall-механикой компонентов ([Component]).
*
* Клиенты видят [Agent] через `:server` / `:client` / `:a2a` — они работают
* с `MutableAgent` через базовый интерфейс и не знают про компоненты.
* Внутри JVM-процесса (`:standalone`, потенциально `:irc-server`, Android-agent)
* хост собирает агента через `MutableAgent` и наращивает его компонентами.
*
* Контракт:
* - [systemProviders] и [toolProviders] — открытые мутабельные списки,
* компонент сам добавляет/убирает свои capability при [install]/[uninstall];
* - [install] / [uninstall] — просто хелперы, делегирующие в `component.{install,uninstall}(this)`;
* - [close] освобождает ресурсы агента и снимает все установленные компоненты.
*
* Состояние порядка: провайдеры исполняются в порядке добавления (порядок
* install-ов компонентов). Если когда-то потребуется приоритизация — расширим
* позже, в v1 держим KISS.
*/
interface MutableAgent : Agent {
/**
* Провайдеры секций system prompt, регистрируются компонентами через [install].
* Каждый [SystemPromptProvider.section] вызывается при каждом построении
* system prompt конкретной беседы; возвращает `null`, если у него нет
* релевантной секции для данного контекста.
*
* Изменяется **только внутри `Component.install(this)` /
* `Component.uninstall(this)`**. Host-код (например, [Main][pw.binom.agentik.standalone.Main])
* напрямую в список не лезет.
*/
val systemProviders: MutableList<SystemPromptProvider>
/**
* Провайдеры tools, регистрируются компонентами через [install].
* [ToolProvider.tools] вызывается при формировании набора тулов
* для конкретной беседы; компонент решает сам, какие тулы отдавать
* (например, разворачивая skill-каталог в `read_skill` / `skill_save`).
*/
val toolProviders: MutableList<ToolProvider>
/**
* Устанавливает [component] в агент: `component.install(this)` +
* агент запоминает компонент, чтобы при [close] корректно его снять.
*
* Возвращает `this` — для fluent-цепочек:
* ```
* ChatAgent(...).install(McpBridgeComponent(reg)).install(MemoryComponent(...))
* ```
*/
fun install(component: Component): MutableAgent
/**
* Снимает [component]: `component.uninstall(this)` + забывает.
* Идемпотентно — повторный `uninstall` для того же компонента безопасен.
*/
fun uninstall(component: Component): MutableAgent
/**
* Оповещает все установленные компоненты, реализующие [ConversationAware],
* о появлении нового разговора. Компонент может подписаться на события,
* запустить фоновые задачи, проиндексировать turns и т.п.
*/
fun attachConversation(handle: ConversationHandle)
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
fun detachConversation(handle: ConversationHandle)
/**
* Освобождает ресурсы агента и снимает все установленные компоненты
* (в обратном порядке, чтобы последний установленный закрыл свои ресурсы
* первым). Idempotent.
*/
override fun close()
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.agent
/**
* Провайдер одной секции system prompt конкретной беседы.
*
* Вызывается [MutableAgent] при каждом построении system prompt
* (на старте беседы и после значимых изменений контекста). Возвращает
* либо markdown-строку секции (будет вставлена в system prompt в порядке
* `base → systemProviders[0].section → systemProviders[1].section → ...`),
* либо `null`, если у провайдера нет релевантной секции для данного
* контекста (например, skill-каталог пуст).
*
* Не-suspend: типичная реализация читает in-memory state (skill-каталог,
* memory-префетч, reflection-снэпшот). Если нужна async-работа — компонент
* сам решает: либо кэширует результат в `AtomicReference` и обновляет из
* своей фоновой корутины, либо использует `runBlocking { ... }` (на свой
* страх и риск, **не** рекомендуется в v1).
*/
fun interface SystemPromptProvider {
/**
* Возвращает markdown-секцию для system prompt или `null`, если секции нет.
*
* [ctx] передаёт контекст беседы ([SystemPromptContext.conversationId])
* и базовый system prompt ([SystemPromptContext.baseSystemPrompt]) —
* если провайдер хочет делать per-conversation разделение, он может.
*/
fun getSection(conversationId: String): String
}
@@ -0,0 +1,33 @@
package pw.binom.agentik.agent
import pw.binom.litert.LiteTool
/**
* Провайдер набора тулов конкретной беседы.
*
* Вызывается [MutableAgent] при формировании списка тулов, доступных
* модели в данной беседе (на старте и при пересборке после существенных
* изменений контекста). Возвращает [LiteTool] напрямую — имя берётся
* из `LiteTool.name` (с v9 это поле часть контракта), а описание и вызов —
* из `describe()` / `invoke()` того же объекта.
*
* Не-suspend: типичная реализация строит список тулов из in-memory state
* (MCP-реестр, skill-каталог, жёстко зашитый набор). Для async-доступа
* к state компонент использует свой собственный scope и кэш.
*
* До v9 [pw.binom.litert] интерфейс [LiteTool] не имел поля `name`, и
* здесь была обёртка `NamedTool(name, LiteTool)`. После обновления до v9
* `LiteTool.name` стал частью контракта — отдельный `NamedTool` стал
* лишним слоем и удалён.
*/
fun interface ToolProvider {
/**
* Возвращает список тулов, доступных модели в беседе [conversationId].
*
* Провайдер может делать per-conversation фильтрацию (например, скрывать
* `skill_save` в read-only-режиме). Если для беседы ничего нет — возвращает
* пустой список.
*/
fun getTools(conversationId: String): List<LiteTool>
}
+3
View File
@@ -24,9 +24,12 @@ kotlin {
api(project(":journal-api")) api(project(":journal-api"))
api(project(":reflection-api")) api(project(":reflection-api"))
api(project(":context-api")) api(project(":context-api"))
api(project(":agent-api"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke) // litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api) api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
api(libs.litert.tools.kotlinx.serialization)
api(libs.kotlinx.coroutines.core) api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core) api(libs.kotlinx.serialization.core)
@@ -1,5 +1,6 @@
package pw.binom.agentik.toolsets package pw.binom.agentik.toolsets
import kotlinx.serialization.Serializable
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
/** /**
@@ -17,20 +18,20 @@ import pw.binom.litert.LiteTool
*/ */
class DisableToolsetTool(private val registry: ToolsetRegistry) { class DisableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool( val tool: LiteTool = liteToolSuspend<DisableArgs>(
describeJson = DESCRIBE, name = NAME,
handler = ::invoke, description = "Deactivate a toolset by name. Its tools become unavailable.",
) ) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String { internal suspend fun invoke(args: DisableArgs): String {
val name = parseName(args) ?: return "missing required argument 'name'" val name = args.name
val toolset = registry.findByName(name) val toolset = registry.findByName(name)
if (toolset != null) { if (toolset != null) {
// Единообразный ответ независимо от текущего состояния.
registry.deactivate(name) registry.deactivate(name)
return "Toolset '$name' deactivated." return "Toolset '$name' deactivated."
} }
// Неизвестный — перечисляем активные (что можно деактивировать)
val actives = registry.activeNames() val actives = registry.activeNames()
return if (actives.isEmpty()) { return if (actives.isEmpty()) {
"Toolset '$name' not found. No toolsets to deactivate." "Toolset '$name' not found. No toolsets to deactivate."
@@ -39,11 +40,10 @@ class DisableToolsetTool(private val registry: ToolsetRegistry) {
} }
} }
@Serializable
internal data class DisableArgs(val name: String)
companion object { companion object {
const val NAME: String = "disable_toolset" const val NAME: String = "disable_toolset"
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
""".trimIndent()
} }
} }
@@ -1,8 +1,6 @@
package pw.binom.agentik.toolsets package pw.binom.agentik.toolsets
import kotlinx.serialization.json.Json import kotlinx.serialization.Serializable
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
/** /**
@@ -20,20 +18,21 @@ import pw.binom.litert.LiteTool
*/ */
class EnableToolsetTool(private val registry: ToolsetRegistry) { class EnableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool( val tool: LiteTool = liteToolSuspend<EnableArgs>(
describeJson = DESCRIBE, name = NAME,
handler = ::invoke, description = "Activate a toolset by name to access its tools.",
) ) { args ->
invoke(args)
}
internal suspend fun invoke(args: String): String { internal suspend fun invoke(args: EnableArgs): String {
val name = parseName(args) ?: return "missing required argument 'name'" val name = args.name
val toolset = registry.findByName(name) val toolset = registry.findByName(name)
if (toolset != null) { if (toolset != null) {
val wasActive = registry.isActive(name) val wasActive = registry.isActive(name)
registry.activate(name) registry.activate(name)
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated." return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
} }
// Неизвестный — перечисляем доступные к активации (inactives)
val inactives = registry.inactiveNames() val inactives = registry.inactiveNames()
return if (inactives.isEmpty()) { return if (inactives.isEmpty()) {
"Toolset '$name' not found. No toolsets available for activation." "Toolset '$name' not found. No toolsets available for activation."
@@ -42,23 +41,10 @@ class EnableToolsetTool(private val registry: ToolsetRegistry) {
} }
} }
@Serializable
internal data class EnableArgs(val name: String)
companion object { companion object {
const val NAME: String = "enable_toolset" const val NAME: String = "enable_toolset"
/**
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
* Соответствует litert-kmp формату LiteTool.describe().
*/
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
""".trimIndent()
} }
} }
/**
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
* Возвращает null если отсутствует или не строка.
*/
internal fun parseName(argsJson: String): String? = runCatching {
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
}.getOrNull()
@@ -1,15 +0,0 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* (имя-как-видит-модель) → [LiteTool].
*
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами.
*
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
* должен жить рядом с другими тулами в `:agent-toolsets`.
*/
data class NamedTool(val name: String, val tool: LiteTool)
@@ -2,9 +2,10 @@ package pw.binom.agentik.toolsets
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import pw.binom.litert.tools.kotlinx.serialization.liteTool
/** /**
* Адаптер из suspend-handler'а в синхронный [LiteTool]. * Обёртка из suspend-handler'а в синхронный [LiteTool].
* *
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это * `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт * упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
@@ -13,10 +14,17 @@ import pw.binom.litert.LiteTool
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам * `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов. * LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
* *
* Сейчас НЕ используется напрямую — современный путь это [liteToolSuspend],
* который генерит JSON-схему из `@Serializable Args` через
* `litert-tools-kotlinx-serialization`. Класс оставлен как escape hatch для
* тулов, чьи описания не получается выразить через `Args` (например, динамические
* JSON Schema, приходящие со стороны).
*
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать * Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста. * `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
*/ */
internal class SyncLiteTool( internal class SyncLiteTool(
override val name: String,
private val describeJson: String, private val describeJson: String,
private val handler: suspend (String) -> String, private val handler: suspend (String) -> String,
) : LiteTool { ) : LiteTool {
@@ -25,9 +33,35 @@ internal class SyncLiteTool(
} }
/** /**
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика. * Строит [LiteTool] из suspend-handler'а и `@Serializable Args`.
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено * JSON-схема генерится автоматически из `Args.descriptor`,
* как API-точка чтобы внешний код не зависел от internal-имени класса. * а сырая строка аргументов десериализуется в типизированный [Args].
*
* Использование:
* ```
* val t: LiteTool = liteToolSuspend<MyArgs>(name = "foo", description = "...") { args ->
* suspendBlock(args) // MyArgs уже распарсен
* }
* ```
*
* Реализация: под капотом используется [pw.binom.litert.tools.kotlinx.serialization.liteTool] —
* его sync-handler запускает наш suspend-handler в [runBlocking].
*/ */
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool = inline fun <reified Args> liteToolSuspend(
SyncLiteTool(describeJson, handler) name: String,
description: String = "",
noinline handler: suspend (Args) -> String,
): LiteTool = liteTool<Args>(
name = name,
description = description,
) { args ->
runBlocking { handler(args) }
}
@PublishedApi
internal val invocationJson: kotlinx.serialization.json.Json = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
isLenient = false
coerceInputValues = true
explicitNulls = false
}
@@ -0,0 +1,103 @@
package pw.binom.agentik.toolsets
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.litert.LiteTool
/**
* Подключает механику toolsets к агенту:
* - [ToolsetRegistry] (per-component instance — раньше жил в ChatAgent).
* - Тулы [EnableToolsetTool] и [DisableToolsetTool] всегда доступны — модель
* ими переключает состояние.
* - Тулы активных тулсетов — динамически: после `enable_toolset(name=X)`
* X.tools становятся видны через [ToolProvider.getTools] уже на
* следующем turn'е.
* - Секция системного промпта — список активных/неактивных тулсетов,
* чтобы модель знала что включено.
*
* Один [ToolsetComponent] на агента. Шарится между беседами через общий
* [MutableAgent] (все conversations читают один [ToolsetRegistry]).
*
* `install(agent)` идемпотентно. `uninstall(agent)` снимает оба провайдера
* по типу (см. [ToolsetToolProvider], [ToolsetSystemProvider]).
*/
class ToolsetComponent(
private val contributions: List<ToolsetContribution>,
) : Component {
/**
* Реестр тулсетов, владеет [ToolsetComponent]. `private` — наружу не светится,
* чтобы никто не дёргал его мимо `enable_toolset`/`disable_toolset` тулов.
*/
private val registry: ToolsetRegistry = ToolsetRegistry(contributions)
private var provider: ToolsetToolProvider? = null
override fun install(agent: MutableAgent) {
val p = ToolsetToolProvider(registry, contributions)
agent.toolProviders.add(p)
provider = p
agent.systemProviders.add(ToolsetSystemProvider(registry))
}
override fun uninstall(agent: MutableAgent) {
provider?.let { agent.toolProviders.remove(it) }
agent.systemProviders.removeAll { it is ToolsetSystemProvider }
}
}
/**
* Возвращает тулсет-тулы в зависимости от текущего состояния реестра:
* - `enable_toolset` / `disable_toolset` — всегда.
* - Тулы активных тулсетов — те, что перечислены в [ToolsetRegistry.activeNames].
*
* Snapshot собирается на каждом вызове [getTools] — диспетчер видит свежее
* состояние после `enable_toolset` уже на следующем turn'е.
*/
class ToolsetToolProvider(
private val registry: ToolsetRegistry,
private val contributions: List<ToolsetContribution>,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> = buildList {
add(EnableToolsetTool(registry).tool)
add(DisableToolsetTool(registry).tool)
// Активные тулсеты — добавляем их тулы в общий пул. Это синхронная
// версия (lock-free snapshot), потому что `getTools` вызывается
// синхронно из `collectTools()`; `active` сам по себе Concurrent-Set
// через Mutex в реестре (все мутации — через activate/deactivate).
val active = runBlockingSnapshot()
contributions.filter { it.name in active }.forEach { c ->
c.tools.forEach { add(it.tool) }
}
}
/**
* Снимает снимок активных имён без suspend-блокировки.
* ToolsetRegistry.activeNames() — suspend, но его можно обойти если
* вычислить через прямой snapshot — для простоты используем runBlocking.
* Это всё равно вызывается на каждый turn, но мьютекс короткий.
*/
private fun runBlockingSnapshot(): Set<String> = kotlinx.coroutines.runBlocking {
registry.activeNames().toSet()
}
}
/**
* Секция системного промпта с описанием доступных тулсетов:
* - `*active*` — что уже подключено.
* - `*inactive*` — что доступно через `enable_toolset`.
*/
class ToolsetSystemProvider(
private val registry: ToolsetRegistry,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String {
val activeNames = kotlinx.coroutines.runBlocking { registry.activeNames() }.toSet()
val all = registry.all()
val active = all.filter { it.name in activeNames }
val inactive = all.filter { it.name !in activeNames }
return SystemPromptToolsetSection.render(active = active, inactive = inactive) ?: ""
}
}
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class DisableToolsetToolTest { class DisableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok" override fun invoke(arguments: String) = "ok"
} }
@@ -32,7 +33,7 @@ class DisableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
reg.activate("media") reg.activate("media")
val r = disable.invoke("""{"name":"media"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
assertEquals("Toolset 'media' deactivated.", r) assertEquals("Toolset 'media' deactivated.", r)
assertEquals(false, reg.isActive("media")) assertEquals(false, reg.isActive("media"))
} }
@@ -42,8 +43,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf( val (disable, _) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
// тулсет изначально неактивен — должно быть тот же ответ (uniform) val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "media"))
val r = disable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' deactivated.", r) assertEquals("Toolset 'media' deactivated.", r)
} }
@@ -55,7 +55,7 @@ class DisableToolsetToolTest {
)) ))
reg.activate("a") reg.activate("a")
reg.activate("b") reg.activate("b")
val r = disable.invoke("""{"name":"unknown"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r) assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
} }
@@ -64,14 +64,7 @@ class DisableToolsetToolTest {
val (disable, _) = harness(listOf( val (disable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()), ToolsetContribution("a", "x", emptyList()),
)) ))
val r = disable.invoke("""{"name":"unknown"}""") val r = disable.invoke(DisableToolsetTool.DisableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r) assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
} }
@Test
fun `missing name argument returns error message`() = runTest {
val (disable, _) = harness(emptyList())
val r = disable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
} }
@@ -8,6 +8,7 @@ import kotlin.test.assertEquals
class EnableToolsetToolTest { class EnableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok" override fun invoke(arguments: String) = "ok"
} }
@@ -31,7 +32,7 @@ class EnableToolsetToolTest {
val (enable, reg) = harness(listOf( val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))), ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
)) ))
val r = enable.invoke("""{"name":"media"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' activated.", r) assertEquals("Toolset 'media' activated.", r)
assertEquals(true, reg.isActive("media")) assertEquals(true, reg.isActive("media"))
} }
@@ -42,7 +43,7 @@ class EnableToolsetToolTest {
ToolsetContribution("media", "media tools", emptyList()), ToolsetContribution("media", "media tools", emptyList()),
)) ))
reg.activate("media") reg.activate("media")
val r = enable.invoke("""{"name":"media"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "media"))
assertEquals("Toolset 'media' already active.", r) assertEquals("Toolset 'media' already active.", r)
} }
@@ -53,7 +54,7 @@ class EnableToolsetToolTest {
ToolsetContribution("b", "y", emptyList()), ToolsetContribution("b", "y", emptyList()),
ToolsetContribution("c", "z", emptyList()), ToolsetContribution("c", "z", emptyList()),
)) ))
val r = enable.invoke("""{"name":"unknown"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r) assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
} }
@@ -63,14 +64,7 @@ class EnableToolsetToolTest {
ToolsetContribution("a", "x", emptyList()), ToolsetContribution("a", "x", emptyList()),
)) ))
reg.activate("a") reg.activate("a")
val r = enable.invoke("""{"name":"unknown"}""") val r = enable.invoke(EnableToolsetTool.EnableArgs(name = "unknown"))
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r) assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
} }
@Test
fun `missing name argument returns error message`() = runTest {
val (enable, _) = harness(emptyList())
val r = enable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
} }
@@ -11,6 +11,7 @@ import kotlin.test.assertTrue
class ToolsetDispatchPolicyTest { class ToolsetDispatchPolicyTest {
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool { private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response override fun invoke(arguments: String) = response
} }
@@ -12,6 +12,7 @@ import kotlin.test.assertTrue
class ToolsetRegistryTest { class ToolsetRegistryTest {
private fun tool(name: String): LiteTool = object : LiteTool { private fun tool(name: String): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}""" override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok:$name" override fun invoke(arguments: String) = "ok:$name"
} }
@@ -27,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
// Подписываемся на поток событий ДО send: события, отправленные // Подписываемся на поток событий ДО send: события, отправленные
// до подписки, не реплеятся (shared-flow без replay). // до подписки, не реплеятся (shared-flow без replay).
val eventsJob = launch { val eventsJob = launch {
conv.events(Instant.DISTANT_PAST) agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
// onEach печатает и терминальный event, takeWhile лишь // onEach печатает и терминальный event, takeWhile лишь
// завершает сбор после него. // завершает сбор после него.
.map { it.event }
.onEach { ev -> emit(ev) } .onEach { ev -> emit(ev) }
.takeWhile { ev -> !isTerminal(ev) } .takeWhile { ev -> !isTerminal(ev) }
.collect { } .collect { }
@@ -53,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
is Event.AppendText -> println("event AppendText ${escape(ev.body)}") is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>") is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}") is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}") is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
is Event.End -> println("event End") is Event.End -> println("event End")
is Event.Interrupted -> println("event Interrupted") is Event.Interrupted -> println("event Interrupted")
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}") is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
@@ -7,7 +7,7 @@ import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -92,12 +92,12 @@ internal class TuiBackend(
} }
/** /**
* Подписывается на [Conversation.events] и перенаправляет их в [state]. * Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
*/ */
private fun subscribeEvents(conv: Conversation, from: Instant) { private fun subscribeEvents(conv: Conversation, from: Instant) {
eventsJob?.cancel() eventsJob?.cancel()
eventsJob = scope.launch { eventsJob = scope.launch {
conv.events(from).collect { ev -> dispatch(ev) } agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
} }
} }
@@ -3,12 +3,13 @@ package pw.binom.agentik.tui
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -31,10 +32,13 @@ internal class FakeAgent(
// emptyFlow, journal — error-on-access (никто не должен его трогать). // emptyFlow, journal — error-on-access (никто не должен его трогать).
override val journal: JournalStore = error("journal not used in TuiBackend tests") override val journal: JournalStore = error("journal not used in TuiBackend tests")
override val outbox: OutboxStore = object : OutboxStore { override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.proto.CommonEvent>() override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {} override fun close() {}
} }
override val conversationStore: ConversationStore = error("conversationStore not used in TuiBackend tests")
override fun createConversation(temp: Boolean): Conversation { override fun createConversation(temp: Boolean): Conversation {
createCount++ createCount++
@@ -49,8 +53,7 @@ internal class FakeAgent(
override suspend fun deleteConversation(id: String): Boolean = override suspend fun deleteConversation(id: String): Boolean =
conversations.removeAll { it.id == id } conversations.removeAll { it.id == id }
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = override suspend fun renameConversation(id: String, title: String?): Instant? = null
conversations.toList()
} }
/** /**
@@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.Event
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@@ -170,7 +170,7 @@ class TuiBackendTest {
runCurrent() runCurrent()
val now = kotlin.time.Clock.System.now() val now = kotlin.time.Clock.System.now()
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}""")) conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok")) conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
runCurrent() runCurrent()
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>() val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
+2 -2
View File
@@ -47,8 +47,8 @@ val moduleDescriptions: Map<String, String> = mapOf(
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).", "memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).", "memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).", "storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).", "storage-inmemory" to "agentik :storage-inmemory — исторический модуль (deleted 2026-09-22; in-memory реализации теперь живут в :journal-inmemory / :reflection-inmemory).",
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).", "storage-sqlite" to "agentik :storage-sqlite — исторический модуль (deleted 2026-09-22; ksqlite-реализации теперь живут в :journal-ksqlite / :context-ksqlite / :reflection-ksqlite).",
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.", "agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.", "agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)." // "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
+192 -4
View File
@@ -16,6 +16,11 @@
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>` - `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens). со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE). - `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
с основным потоком событий. См. ниже.
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно `Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины. вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины.
@@ -36,6 +41,30 @@ dependencies {
} }
``` ```
## Что клиент хранит локально (persistence)
Либа **не** имеет `SettingsRepository` / `Config` — это намеренно: UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain, Android DataStore, NSUserDefaults, ...). Либа не навязывает формат, но клиент должен сериализовать у себя минимум:
| Поле | Что это | Где взять |
|---|---|---|
| `clientId` (параметр `id` в `AgentikAgent`) | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id — это **произвольная строка клиента**, обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется один раз при первом запуске (`UUID.randomUUID().toString()`) и сохраняется. Никогда не меняется. |
| `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
| `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
Опционально (для UX): `engineFactory` — обычно compile-time выбор по платформе (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS).
Минимальный JSON для UI, который хранит в файле:
```json
{
"clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
"baseUrl": "https://agent.example.com/agentik",
"token": "s3cret"
}
```
⚠️ `clientId` **генерируется один раз** при установке и больше не меняется — иначе сломается log multiplexing на сервере.
## Быстрый старт: свой клиент за 5 минут ## Быстрый старт: свой клиент за 5 минут
Один self-contained пример: создаём агента, открываем диалог, Один self-contained пример: создаём агента, открываем диалог,
@@ -256,6 +285,55 @@ session.scope.launch {
`rec is MessageRecord.UserMessage` для реплик пользователя, `rec is MessageRecord.UserMessage` для реплик пользователя,
`rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п. `rec is MessageRecord.ToolCall` для отрисовки tool-call баббла, и т.п.
## Кэш списка бесед
`agent.conversationStore` — read-only view поверх `conversation`-таблицы
на сервере (`ConversationRecord` = id / title / isTemporal / createdAt /
updatedAt, без `Conversation` handle и без флагов image-support).
**Сценарий клиента:** показать список диалогов («как в Telegram»), чтобы
при открытии UI уже знал названия, не дёргал сервер лишний раз, и
моментально реагировал на создание/удаление/переименование в другой
вкладке.
Подход — тот же **«remote → local snapshot + live-events»**:
```kotlin
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.outbox.AgentEvent
import io.ktor.client.engine.cio.CIO
// `AgentikAgent` сам оборачивает HTTP-store в локальный кэш:
// remote.listFlow → local.upsert (snapshot)
// outbox.agentEvents → local.upsert / delete (live)
val agent = AgentikAgent(
id = "agentik",
baseUrl = "http://localhost:8080/agentik",
engineFactory = CIO,
)
// Кэш уже наполняется в фоне, читать можно сразу:
val all = agent.conversationStore.list(0, Int.MAX_VALUE)
all.forEach { rec -> println("${rec.id} ${rec.title ?: "(no title)"} ${rec.updatedAt}") }
// И наблюдать live-изменения (Created/Deleted/Renamed/Touched)
agent.outbox.agentEvents(kotlin.time.Instant.DISTANT_PAST).collect { ev ->
when (ev) {
is AgentEvent.Created -> println("+ ${ev.conversationId}")
is AgentEvent.Renamed -> println("~ ${ev.id} → ${ev.title}")
is AgentEvent.Touched -> println("↻ ${ev.id} (${ev.updatedAt})")
is AgentEvent.Deleted -> println("- ${ev.id}")
}
}
```
Если ты **не хочешь** встроенный кэш (например, тебе нужен прямой HTTP
для бэкенда-сервиса) — `agentikHttpClient(...).raw` оставлен как
escape-hatch. Сам `InMemoryMutableConversationStore` тоже доступен —
подмени его на свою реализацию через `wrapWithLocalConversationCache`,
если нужен SQLite/JSON-store.
## Стриминг live-ответа ## Стриминг live-ответа
Для streaming-рендера текущего хода подписывайся на `events()` и Для streaming-рендера текущего хода подписывайся на `events()` и
@@ -308,13 +386,81 @@ UI-обновление списка — отдельная задача, реш
- **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только - **UI-рендеринг** — это твоя зона (Compose/HTML/etc.), `:client` только
отдаёт типы и потоки. отдаёт типы и потоки.
- **Персистентность кэша** — `InMemoryJournalStore` хранит в RAM. Для - **Персистентность кэша** — `InMemoryJournalStore` и
диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в `InMemoryMutableConversationStore` хранят в RAM. Для диска пиши свой
`:journal-ksqlite` как образец). `MutableJournalStore` / `MutableConversationStore` (см. `KsqliteJournalStore`
в `:journal-ksqlite` как образец).
- **Нестандартные движковые настройки** — для `requestTimeout`, - **Нестандартные движковые настройки** — для `requestTimeout`,
прокси и т.п. используй `agentikHttpClient(engineFactory, token)` прокси и т.п. используй `agentikHttpClient(engineFactory, token)`
напрямую. напрямую.
## Кэш списка бесед
`agent.conversationStore`, который видит клиент — это **локальный кэш**,
а не прямой HTTP. Внутри `AgentikAgent` (в `wrapWithLocalConversationCache`)
лежит `InMemoryMutableConversationStore`, синхронизированный с сервером:
1. **Seed при старте**: один snapshot через `remote.listFlow(0)` → заливаем
в `localStore.upsert(...)`.
2. **Live-обновления**: подписка на `outbox.agentEvents(after)`:
- `Created(id)` → `remote.get(id)` → `local.upsert(record)`
- `Deleted(id)` → `local.delete(id)`
- `Renamed(id, title)` → `local.rename(id, title)`
- `Touched(id, updatedAt)` → `local.touch(id, updatedAt)`
UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно, без
HTTP, в т.ч. оффлайн. Список бесед всегда свежий: сервер эмитит
`AgentEvent.Created` / `Deleted` / `Renamed` / `Touched` в свой outbox,
клиент видит их через SSE и применяет к локальной копии.
**Команды** (создать / переименовать / удалить) идут через `agent`:
```kotlin
// Создать новую беседу:
val conv = agent.createConversation(temp = false) // → POST /conversations
// → server эмитит Created
// → client cache получает Created
// → UI увидит её в списке
// Переименовать:
agent.renameConversation(conv.id, "Новый заголовок") // → PATCH /conversations/{id}
// → server эмитит Renamed
// → client cache обновляет title
// Удалить:
agent.deleteConversation(conv.id) // → DELETE /conversations/{id}
// → server эмитит Deleted
// → client cache удаляет запись
```
`conversationStore` доступен **только для чтения**. Это read-only projection
на серверную таблицу `conversation` (id + title + timestamps). Для активной
работы (send / interrupt) получай handle через `agent.getConversation(id)`.
**Никогда не пиши в `conversationStore` напрямую.** Все модификации —
командами `agent.createConversation / deleteConversation / renameConversation`.
### Если хочется своего cache-импла
`InMemoryMutableConversationStore` подходит для 99% случаев — Map +
Mutex, KMP, тесты зелёные. Если нужен диск (cold-start восстановление
после перезапуска) — реализуй свой `MutableConversationStore` поверх
SQLite/Room/Core Data, см. `KsqliteMutableConversationStore` в
`:journal-ksqlite` как образец.
```kotlin
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ConversationRecord
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
override suspend fun delete(id: String): Boolean { /* DELETE */ }
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
override suspend fun touch(id: String, now: Instant) { /* UPDATE updatedAt */ }
override fun close() {}
}
```
## Тесты ## Тесты
``` ```
@@ -322,7 +468,49 @@ UI-обновление списка — отдельная задача, реш
``` ```
Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва, Покрывают: JSON-парсинг `Event`-ов, SSE-стрим, recovery после разрыва,
401/404. 401/404, reconnect-cycle `ReconnectingOutbox` (4 кейса: успех / обрыв +
reconnect / exhausted attempts → Failed / close → cancel).
## Auto-reconnect для живого outbox
Базовый `OutboxStore.events(after)` — cold SSE-стрим, при обрыве (мобильная
сеть, рестарт сервера) клиент сам должен реконнектиться с `after = lastEventDate`.
Это повторяется в каждом клиенте. `ReconnectingOutbox` берёт это на себя:
```kotlin
val recon = ReconnectingOutbox(
outbox = agent.outbox, // или HttpEventStore
scope = myScreenScope,
policy = BackoffPolicy.Default, // 1s → 2s → ... → 30s, ±20% jitter
)
scope.launch { recon.events(Instant.DISTANT_PAST).collect { handle(it) } }
scope.launch {
recon.connectionStatus().collect { status ->
when (status) {
is Connecting -> ui.showBanner("connecting...")
is Connected -> ui.hideBanner()
is Disconnected -> ui.showBanner("reconnecting in ${status.willRetryIn}…")
is Failed -> ui.showError(status.cause)
}
}
}
// На выходе (например, navigation back):
recon.close() // отменяет background-loop, потоки терминируются
```
Два потока **независимы** — `events()` содержит только `CommonEvent`,
`connectionStatus()` содержит только `ConnectionStatus`. Никакого
"мешающего" `Connecting`/`Disconnected` в потоке событий.
Параметры backoff (см. `BackoffPolicy`):
- `initial` / `max` — границы задержки
- `multiplier` — множитель на каждом шаге
- `jitter` — рандом-разброс (по умолчанию 20%)
- `maxAttempts` — лимит попыток; после — `Failed` + закрытие потока
Если нужен фиксированный delay для тестов — `BackoffPolicy.Fixed(10.milliseconds, attempts = 3)`.
## Известное ограничение ## Известное ограничение
+1
View File
@@ -23,6 +23,7 @@ kotlin {
api(project(":proto")) api(project(":proto"))
api(project(":outbox-api")) api(project(":outbox-api"))
api(project(":journal-api")) api(project(":journal-api"))
implementation(project(":journal-inmemory"))
api(libs.ktor.client.core) api(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
@@ -5,25 +5,28 @@ import io.ktor.client.call.body
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/** /**
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`. * HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
* *
* HttpClient создаётся внутри из переданного engine и закрывается в [close]. * HttpClient создаётся внутри из переданного engine и закрывается в [close].
* *
* **Storage handles** ([journal], [outbox]) — read-only views на серверные * **Storage handles** ([journal], [outbox], [conversationStore]) — read-only
* хранилища. * views на серверные хранилища. Запись — только через команды
* [createConversation] / [deleteConversation] / [renameConversation].
*/ */
internal class AgentClient( internal class AgentClient(
override val id: String, override val id: String,
@@ -35,6 +38,7 @@ internal class AgentClient(
override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl) override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl)
override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl) override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl)
override val conversationStore: ConversationStore = HttpConversationStore(httpClient = httpClient, baseUrl = agentUrl)
override fun createConversation(temp: Boolean): Conversation = override fun createConversation(temp: Boolean): Conversation =
runBlocking { runBlocking {
@@ -53,16 +57,18 @@ internal class AgentClient(
} }
override suspend fun deleteConversation(id: String): Boolean { override suspend fun deleteConversation(id: String): Boolean {
val response: HttpResponse = httpClient.delete("$agentUrl/conversations/$id") val response = httpClient.delete("$agentUrl/conversations/$id")
return response.status == HttpStatusCode.NoContent return response.status == HttpStatusCode.NoContent
} }
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> { override suspend fun renameConversation(id: String, title: String?): Instant? {
val snapshots = httpClient.get("$agentUrl/conversations") { val response = httpClient.patch("$agentUrl/conversations/$id") {
parameter("offset", offset) contentType(ContentType.Application.Json)
parameter("limit", limit) setBody(RequestRename(title))
}.body<List<ConversationSnapshot>>() }
return snapshots.map { ConversationClient(httpClient, agentUrl, it) } if (response.status == HttpStatusCode.NotFound) return null
val rec = response.body<pw.binom.agentik.journal.ConversationRecord>()
return rec.updatedAt
} }
override fun close() { override fun close() {
@@ -1,7 +1,20 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.HttpClientEngineFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import kotlin.time.Instant
/** /**
* Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`). * Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`).
@@ -20,24 +33,141 @@ import pw.binom.agentik.proto.Agent
* ) * )
* val conv = agent.createConversation(temp = false) * val conv = agent.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi"))) * conv.send(listOf(Content.Text("hi")))
* conv.events(Instant.DISTANT_PAST).collect { ... } * agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
* agent.close() // закрывает HttpClient * .map { it.event }
* .collect { ... }
* agent.close() // закрывает HttpClient + локальный кэш
* ``` * ```
* *
* [id] пробрасывается в `Agent.id` — сервер про идентичность агента не знает, * ## Что клиент должен хранить локально (persistence)
* поэтому клиент должен её знать сам (или взять из конфига). *
* Либа **не** имеет `SettingsRepository` / `Config` — это намеренно:
* UI-фреймворки хранят настройки по-разному (JSON-файл, Keychain,
* `SharedPreferences`, Android DataStore, NSUserDefaults, ...). Либа
* не навязывает формат, но вот минимальный набор, который клиент должен
* сериализовать у себя, чтобы пережить перезапуск:
*
* | Поле | Что это | Где взять |
* |---|---|---|
* | `id` | Идентичность клиента в логах сервера (X-Client-Id header). Не user-id в агенте, не device-id, а произвольная строка клиента — обычно `<app-name>-<installation-uuid>`. Сервер использует для log multiplexing и не интерпретирует. | Генерируется клиентом при первом запуске, сохраняется локально |
* | `baseUrl` | URL сервера (`http://host:8080/agentik`). Должен включать path-prefix фасада, не только хост. | Из настроек пользователя / дефолт |
* | `token` | Bearer-токен. `null` = анонимный доступ (если сервер разрешает). | Из настроек пользователя / secure-storage |
*
* Опционально (для UX):
* | Поле | Зачем |
* |---|---|
* | `engineFactory` | Зависит от платформы (`CIO` JVM/Native, `OkHttp` JVM, `Darwin` iOS/macOS). Выбор — обычно compile-time. |
*
* Пример минимального persistence-файла (для UI, который хранит JSON):
*
* ```json
* {
* "clientId": "my-android-app-550e8400-e29b-41d4-a716-446655440000",
* "baseUrl": "https://agent.example.com/agentik",
* "token": "s3cret"
* }
* ```
*
* `clientId` генерируется один раз при первой установке (`UUID.randomUUID().toString()`)
* и больше не меняется — иначе сломается log multiplexing на сервере.
*
* ## Локальный кэш списка бесед
*
* [conversationStore], который видит клиент — это **кэш**, не прямой HTTP.
* Внутри лежит [InMemoryMutableConversationStore], который:
* 1. На старте делает snapshot через `remote.listFlow(0)` → `local.upsert(...)`.
* 2. Подписывается на `outbox.agentEvents(after)` → для каждого
* [AgentEvent.Created] / `Deleted` / `Renamed` / `Touched` применяет
* соответствующий `upsert/delete/rename/touch` к локальной копии.
*
* UI читает `agent.conversationStore.list(0, PAGE_SIZE)` — мгновенно,
* без HTTP, в т.ч. оффлайн. Команды (create/delete/rename) идут
* через [Agent] и **не** через `conversationStore` (он read-only).
* *
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает * **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
* HttpClient (идемпотентно). После этого `createConversation` / * HttpClient + локальный кэш + background-coroutine (идемпотентно).
* `getConversation` etc. не определены. * После этого `createConversation` / `getConversation` etc. не определены.
*/ */
fun AgentikAgent( fun AgentikAgent(
id: String, id: String,
baseUrl: String, baseUrl: String,
engineFactory: HttpClientEngineFactory<*>, engineFactory: HttpClientEngineFactory<*>,
token: String? = null, token: String? = null,
): Agent = AgentClient( ): Agent {
id = id, val httpClient = agentikHttpClient(engineFactory = engineFactory, token = token)
baseUrl = baseUrl, val client = AgentClient(id = id, baseUrl = baseUrl, httpClient = httpClient)
httpClient = agentikHttpClient(engineFactory = engineFactory, token = token), return wrapWithLocalConversationCache(client, scopeClient = client)
) }
/**
* Оборачивает [Agent] так, что [Agent.conversationStore] становится
* локальным in-memory кэшем, синхронизированным с удалённым стором
* через outbox-события.
*
* - **Seed**: при создании делает один snapshot через
* `remote.listFlow(0)` и заливает в [InMemoryMutableConversationStore].
* - **Live**: подписка на `agent.outbox.agentEvents(after)` применяет
* `Created` / `Deleted` / `Renamed` / `Touched` к локальному кэшу.
*
* Возвращает обёртку, у которой переопределён только [Agent.conversationStore]
* (на read-only projection локального [InMemoryMutableConversationStore]).
* Остальные методы [Agent] — delegated в [delegate].
*/
private fun wrapWithLocalConversationCache(
delegate: Agent,
scopeClient: Agent,
): Agent = object : Agent by delegate {
private val localStore: MutableConversationStore = InMemoryMutableConversationStore()
private val cacheScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val syncJob: Job
init {
// Делаем cacheStore read-only view на localStore.
// (Через вложенный класс — см. ниже.)
// Запускаем seed + live-refresh параллельно.
syncJob = cacheScope.launch {
// 1. seed — snapshot всех текущих бесед с сервера
try {
delegate.conversationStore.listFlow(offset = 0, pageSize = ConversationStore.PAGE_SIZE)
.collect { rec -> localStore.upsert(rec) }
} catch (_: Throwable) {
// seed может упасть (offline / 5xx) — не критично,
// live-источник всё равно догонит при первом событии.
}
// 2. live — применяем outbox-события.
// Используем `first()` для knownId после Created — потом отписываемся,
// потому что Created нужно вытянуть полный record через `remote.get(id)`.
// Renamed/Touched меняют локальную копию без round-trip.
delegate.outbox.agentEvents(after = Instant.DISTANT_PAST).collect { ce ->
when (val ev = ce.event) {
is AgentEvent.Created -> {
// Created не несёт title/timestamps — нужно сходить в remote.
val rec = delegate.conversationStore.get(ev.conversationId)
if (rec != null) localStore.upsert(rec)
}
is AgentEvent.Deleted -> localStore.delete(ev.id)
is AgentEvent.Renamed -> localStore.rename(ev.id, ev.title)
is AgentEvent.Touched -> localStore.touch(ev.id, ev.updatedAt)
}
}
}
}
/**
* Read-only projection локального кэша — клиент через него только
* читает (`get` / `list` / `listFlow`).
*/
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String): ConversationRecord? = localStore.get(id)
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> = localStore.list(offset, limit)
override fun close() {} // owned by outer close
}
override fun close() {
cacheScope.cancel()
runBlocking { syncJob.join() }
delegate.close()
}
}
@@ -6,18 +6,12 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.patch import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant import kotlin.time.Instant
@@ -72,22 +66,6 @@ internal class ConversationClient(
httpClient.post("$convUrl/interrupt") httpClient.post("$convUrl/interrupt")
} }
override fun events(after: Instant): Flow<Event> = flow {
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
}
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
httpClient.get("$convUrl/messages") { httpClient.get("$convUrl/messages") {
parameter("after", after.toString()) parameter("after", after.toString())
@@ -23,4 +23,4 @@ data class ConversationSnapshot(
internal data class RequestCreateConversation(val temp: Boolean) internal data class RequestCreateConversation(val temp: Boolean)
@Serializable @Serializable
internal data class RequestRename(val title: String) internal data class RequestRename(val title: String?)
@@ -0,0 +1,58 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.HttpStatusCode
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
/**
* HTTP-реализация [ConversationStore] (read-only metadata view),
* ходящая в `:server`-фасад.
*
* **Endpoint**: `GET {baseUrl}/conversations?offset=&limit=` —
* возвращает `List<ConversationRecord>` (id, title, isTemporal, createdAt,
* updatedAt) БЕЗ handle'ов и image-support флагов (это лёгкая проекция
* для UI-списка; handle берётся через `agent.getConversation(id)`).
*
* **Read-only**: запись в `conversation` table — только через команды
* `agent.createConversation / deleteConversation / renameConversation`.
*
* Клиентский кэш строится композицией `HttpConversationStore` (snapshot)
* + `agent.outbox.agentEvents(after)` (live deltas: Created/Deleted/
* Renamed/Touched) — см. `client/README.md` секция
* «Кэш списка бесед».
*/
internal class HttpConversationStore(
private val httpClient: HttpClient,
private val baseUrl: String,
) : ConversationStore {
private val agentUrl: String = baseUrl.trimEnd('/')
override suspend fun get(id: String): ConversationRecord? {
val response = httpClient.get("$agentUrl/conversations/$id")
if (response.status == HttpStatusCode.NotFound) return null
check(response.status == HttpStatusCode.OK) {
"conversationStore.get($id): server returned ${response.status}"
}
return response.body<ConversationRecord>()
}
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> {
val response = httpClient.get("$agentUrl/conversations") {
parameter("offset", offset)
parameter("limit", limit)
}
check(response.status == HttpStatusCode.OK) {
"conversationStore.list: server returned ${response.status}"
}
return response.body<List<ConversationRecord>>()
}
override fun close() {
// HttpClient закрывает владелец (AgentClient / AgentikAgent).
}
}
@@ -0,0 +1,242 @@
package pw.binom.agentik.client
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.math.min
import kotlin.math.pow
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Состояние подключения к удалённому [OutboxStore]. Эмитится через
* [ReconnectingOutbox.connectionStatus] — отдельным потоком, **не**
* смешивается с [ReconnectingOutbox.events].
*
* Типичный цикл:
* ```
* Connecting(1) → Connected → ... → Disconnected(reason, retryIn) →
* Connecting(2) → Connected → ...
* ```
* При полном исчерпании попыток ([BackoffPolicy.maxAttempts]) —
* финальный [Failed].
*/
sealed interface ConnectionStatus {
/** Начата попытка подключения (включая первую — `attempt == 1`). */
data class Connecting(val attempt: Int) : ConnectionStatus
/** Получен первый event с сервера после [Connecting] / [Disconnected]. */
data class Connected(val since: Instant) : ConnectionStatus
/**
* Стрим оборвался (network error, server close, таймаут). [reason] —
* причина, `null` если штатное завершение. [willRetryIn] — через сколько
* будет следующая попытка (`null` если [Failed]).
*/
data class Disconnected(
val reason: Throwable?,
val willRetryIn: Duration?,
) : ConnectionStatus
/**
* Все попытки исчерпаны ([BackoffPolicy.maxAttempts]). Поток [events]
* закрывается после этого. Создатель [ReconnectingOutbox] должен
* решить, что делать — показать ошибку пользователю, пересоздать
* outbox и т.п.
*/
data class Failed(val cause: Throwable) : ConnectionStatus
}
/**
* Политика backoff для [ReconnectingOutbox]. Параметры:
*
* - [initial] — задержка перед первой retry-попыткой.
* - [max] — потолок задержки (после серии умножений).
* - [multiplier] — множитель на каждом шаге (например, `2.0` → 1s, 2s, 4s, 8s, ...).
* - [jitter] — доля случайного разброса `[0, jitter]` от текущей задержки
* (например, `0.2` = ±20%). Снижает thundering-herd при массовом reconnect.
* - [maxAttempts] — лимит попыток. `Int.MAX_VALUE` = бесконечно.
*/
data class BackoffPolicy(
val initial: Duration = 1.seconds,
val max: Duration = 30.seconds,
val multiplier: Double = 2.0,
val maxAttempts: Int = Int.MAX_VALUE,
val jitter: Double = 0.2,
) {
init {
require(initial > Duration.ZERO) { "initial must be positive" }
require(max >= initial) { "max must be >= initial" }
require(multiplier >= 1.0) { "multiplier must be >= 1.0" }
require(maxAttempts >= 1) { "maxAttempts must be >= 1" }
require(jitter in 0.0..1.0) { "jitter must be in [0, 1]" }
}
companion object {
/** 1s → 2s → 4s → ... → 30s, jitter ±20%, бесконечные попытки. */
val Default: BackoffPolicy = BackoffPolicy()
/** Только для тестов: фиксированные задержки без разброса. */
fun Fixed(delay: Duration, attempts: Int = 3): BackoffPolicy =
BackoffPolicy(
initial = delay,
max = delay,
multiplier = 1.0,
maxAttempts = attempts,
jitter = 0.0,
)
}
}
/**
* Обёртка над [OutboxStore] с автоматическим reconnect при обрыве стрима.
*
* **Два независимых потока**:
* - [events] — `Flow<CommonEvent>`, тот же контракт что [OutboxStore.events],
* но с автоматическим переподключением через [BackoffPolicy]. Cursor
* (`lastSeen`) сохраняется между попытками — клиент не теряет события.
* - [connectionStatus] — `Flow<ConnectionStatus>`, **параллельный** поток
* lifecycle подключения. Не смешивается с [events].
*
* ```
* val outbox = ReconnectingOutbox(httpEventStore, scope)
*
* scope.launch {
* outbox.events(after = Instant.DISTANT_PAST).collect { e -> handle(e) }
* }
* scope.launch {
* outbox.connectionStatus().collect { s -> ui.showStatus(s) }
* }
*
* // На выходе:
* outbox.close() // отменяет background-loop, эмитит Cancelled-как-Disconnected
* ```
*
* Создатель передаёт свой [scope] — жизненный цикл reconnect-цикла
* привязан к нему. Закрытие scope (или явный [close]) отменяет
* background-loop. После [close] оба flow терминируются.
*/
class ReconnectingOutbox(
private val outbox: OutboxStore,
private val scope: CoroutineScope,
private val policy: BackoffPolicy = BackoffPolicy.Default,
private val random: Random = Random.Default,
) : AutoCloseable {
private val _events = MutableSharedFlow<CommonEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val _status = MutableSharedFlow<ConnectionStatus>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@OptIn(ExperimentalAtomicApi::class)
private val started = AtomicBoolean(false)
private var job: Job? = null
@OptIn(ExperimentalAtomicApi::class)
private val lastSeen: AtomicReference<Instant?> = AtomicReference(null)
/**
* Live-события из [outbox] с авто-reconnect. [after] — начальный курсор;
* учитывается только при первом вызове (любом из [events] /
* [connectionStatus]). После reconnect курсор берётся из `date`
* последнего виденного события.
*
* Коллекторы независимы — каждый получает свою копию потока (shared).
* Медленный коллектор может пропускать события при переполнении буфера
* (`DROP_OLDEST`).
*/
fun events(after: Instant? = null): Flow<CommonEvent> {
ensureStarted(after)
return _events
}
/**
* Lifecycle подключения: [ConnectionStatus.Connecting] /
* [ConnectionStatus.Connected] / [ConnectionStatus.Disconnected] /
* [ConnectionStatus.Failed]. **Не смешивается** с [events] — это
* отдельный поток для UI-индикации статуса сети.
*/
fun connectionStatus(): Flow<ConnectionStatus> {
ensureStarted(null)
return _status
}
@OptIn(ExperimentalAtomicApi::class)
private fun ensureStarted(initialCursor: Instant?) {
if (!started.compareAndSet(false, true)) return
lastSeen.store(initialCursor)
job = scope.launch { runLoop() }
}
@OptIn(ExperimentalAtomicApi::class)
private suspend fun runLoop() {
var attempt = 0
var connected = false
while (currentCoroutineContext().isActive) {
attempt++
_status.emit(ConnectionStatus.Connecting(attempt))
val error: Throwable? = try {
outbox.events(after = lastSeen.load()).collect { event ->
lastSeen.store(event.date)
_events.emit(event)
if (!connected) {
connected = true
_status.emit(ConnectionStatus.Connected(event.date))
}
}
null
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
t
}
connected = false
if (attempt >= policy.maxAttempts) {
_status.emit(
ConnectionStatus.Failed(error ?: RuntimeException("outbox flow ended normally"))
)
return
}
val backoff = computeBackoff(attempt)
_status.emit(ConnectionStatus.Disconnected(error, backoff))
delay(backoff)
}
}
private fun computeBackoff(attempt: Int): Duration {
// attempt 1 → initial, 2 → initial * m, 3 → initial * m^2, ...
val base = (policy.initial.inWholeMilliseconds.toDouble() *
policy.multiplier.pow((attempt - 1).toDouble()))
.toLong()
val capped = min(base, policy.max.inWholeMilliseconds)
val jitterMs = (capped * policy.jitter * random.nextDouble()).toLong()
val finalMs = (capped + jitterMs).coerceAtLeast(1L)
return Duration.parse("${finalMs}ms")
}
override fun close() {
job?.cancel()
job = null
}
}
@@ -0,0 +1,211 @@
package pw.binom.agentik.client
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.outbox.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* In-memory [OutboxStore] для unit-тестов [ReconnectingOutbox].
*
* Управление:
* - [push] — кладёт [CommonEvent] в очередь, флоу доставит.
* - [throwAtNextEvent] — следующий «тик» `events(after)` бросит этот Throwable
* (симулирует network error / stream break).
*
* Сигнатура [events] идентична боевой — её можно подменить боевым
* `HttpEventStore`, контракт один и тот же.
*/
internal class FakeOutbox : OutboxStore {
private sealed interface Msg {
data class Ev(val event: CommonEvent) : Msg
data class Err(val throwable: Throwable) : Msg
}
private val channel = Channel<Msg>(Channel.UNLIMITED)
override fun events(after: Instant?): Flow<CommonEvent> = flow {
for (msg in channel) {
when (msg) {
is Msg.Err -> throw msg.throwable
is Msg.Ev -> emit(msg.event)
}
}
}
fun push(event: CommonEvent) { channel.trySend(Msg.Ev(event)) }
fun throwAtNextEvent(t: Throwable) { channel.trySend(Msg.Err(t)) }
override fun agentEvents(after: Instant?): Flow<CommonEvent.Agent> = emptyFlow()
override fun conversationEvents(
after: Instant?,
conversationId: String?,
): Flow<CommonEvent.Conversation> = emptyFlow()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() { channel.close() }
}
private fun testEvent(dateMs: Long): CommonEvent =
CommonEvent.Conversation(
date = Instant.fromEpochMilliseconds(dateMs),
conversationId = "test",
event = Event.End(date = Instant.fromEpochMilliseconds(dateMs)),
)
@OptIn(ExperimentalCoroutinesApi::class)
class ReconnectingOutboxTest {
@Test
fun `first event after connect emits Connecting then Connected`() = runConnectionTest(
attempts = 5,
) { ctx ->
val fake = ctx.fake
val status = ctx.statusLog
val events = ctx.eventsLog
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, status.count { it is ConnectionStatus.Connecting && it.attempt == 1 })
assertEquals(1, status.count { it is ConnectionStatus.Connected })
assertEquals(1, events.size)
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
}
@Test
fun `disconnect mid-stream triggers retry with backoff and resumes from last seen`() =
runConnectionTest(attempts = 5) { ctx ->
val fake = ctx.fake
val status = ctx.statusLog
val events = ctx.eventsLog
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, events.size)
// Имитируем обрыв стрима после первого события.
fake.throwAtNextEvent(RuntimeException("simulated network error"))
ctx.advanceAndDrain(50)
// После Disconnected должен прийти Connecting(2), затем Connected,
// затем новые события без дубля предыдущего.
val disconnectedIndex = status.indexOfFirst { it is ConnectionStatus.Disconnected }
val connecting2Index = status.indexOfFirst {
it is ConnectionStatus.Connecting && it.attempt == 2
}
assertTrue(disconnectedIndex >= 0, "no Disconnected emitted, got: $status")
assertTrue(connecting2Index > disconnectedIndex,
"expected Connecting(2) after Disconnected, got: $status")
// Push a new event with later date — cursor preserves lastSeen.
fake.push(testEvent(2000))
ctx.advanceAndDrain(50)
assertEquals(2, events.size)
assertEquals(Instant.fromEpochMilliseconds(1000), events[0].date)
assertEquals(Instant.fromEpochMilliseconds(2000), events[1].date)
}
@Test
fun `exhausted attempts emits Failed and closes flow`() = runConnectionTest(
attempts = 3,
) { ctx ->
val fake = ctx.fake
// Каждая попытка connect бросает — все 3 попытки fail.
for (i in 0 until 3) {
fake.throwAtNextEvent(RuntimeException("server is dead #${i + 1}"))
ctx.advanceAndDrain(50)
}
val failed = ctx.statusLog.filterIsInstance<ConnectionStatus.Failed>().firstOrNull()
assertNotNull(failed) { "expected Failed status, got: ${ctx.statusLog}" }
assertTrue(failed.cause is RuntimeException)
assertEquals(0, ctx.eventsLog.size)
}
@Test
fun `close cancels background loop`() = runConnectionTest(
attempts = 5,
) { ctx ->
val fake = ctx.fake
fake.push(testEvent(1000))
ctx.advanceAndDrain(50)
assertEquals(1, ctx.eventsLog.size)
ctx.recon.close()
ctx.advanceAndDrain(100)
// После close запуск новых эмиссий не должен происходить.
val beforePush = ctx.eventsLog.size
fake.push(testEvent(2000))
ctx.advanceAndDrain(100)
assertEquals(beforePush, ctx.eventsLog.size)
}
private data class TestCtx(
val fake: FakeOutbox,
val recon: ReconnectingOutbox,
val statusLog: MutableList<ConnectionStatus>,
val eventsLog: MutableList<CommonEvent>,
val jobs: List<Job>,
val scope: CoroutineScope,
val advanceAndDrain: (Long) -> Unit,
)
/**
* Запускает [ReconnectingOutbox] с policy из `attempts` попыток по 10ms,
* сабскрайбит на оба потока в собирающие лист, и возвращает [TestCtx]
* с управляемым `advanceAndDrain(ms)` — прокрутить виртуальное время.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun runConnectionTest(
attempts: Int,
block: suspend (TestCtx) -> Unit,
) = runTest {
val policy = BackoffPolicy.Fixed(
delay = Duration.parse("10ms"),
attempts = attempts,
)
val fake = FakeOutbox()
val recon = ReconnectingOutbox(
outbox = fake,
scope = this,
policy = policy,
)
val statusLog = mutableListOf<ConnectionStatus>()
val eventsLog = mutableListOf<CommonEvent>()
val jobs = listOf(
launch { recon.connectionStatus().collect { statusLog.add(it) } },
launch { recon.events().collect { eventsLog.add(it) } },
)
val advanceAndDrain: (Long) -> Unit = { ms ->
if (ms > 0) advanceTimeBy(ms)
runCurrent()
}
try {
TestCtx(fake, recon, statusLog, eventsLog, jobs, this, advanceAndDrain).also { block(it) }
} finally {
recon.close()
jobs.forEach { it.cancel() }
fake.close()
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ plugins {
// агента. // агента.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -17,20 +17,41 @@ import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [ContextStore] (таблица `working_memory`). * ksqlite-реализация [ContextStore] (таблица `working_memory`).
* *
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteWorkingMemoryStore] * Единственный владелец таблицы `working_memory` в проекте. Используется
* из `:storage-ksqlite`, но: * напрямую через `:context-ksqlite` зависимость; bundle'ом собирает
* - лежит в собственном модуле `:context-ksqlite`; * `pw.binom.agentik.standalone.persistence.SqliteStores`.
* - реализует переименованный [ContextStore] (раньше был `WorkingMemoryStore`,
* теперь главный класс — `ContextStore`); сами типы строк
* [WorkingMemoryEntry] / [WorkingMemoryRow] не переименовывались.
* *
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteWorkingMemoryStore.kt` остаётся на диске — * ## Lifecycle соединения
* это копия, не замена. Не удалять старый файл; миграция consumers'ов — отдельно. *
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteContextStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close].
* - `KsqliteContextStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteContextStore.memory(name)` — in-memory, закрывает в [close].
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании (idempotent).
*/ */
class KsqliteContextStore( class KsqliteContextStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : ContextStore { ) : ContextStore {
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -179,6 +200,15 @@ class KsqliteContextStore(
maxOrderIdxStmt.close() maxOrderIdxStmt.close()
dropFromIdxStmt.close() dropFromIdxStmt.close()
insertSummaryStmt.close() insertSummaryStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteContextStore =
KsqliteContextStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
private fun maxOrderIdx(conversationId: String): Long { private fun maxOrderIdx(conversationId: String): Long {
@@ -12,7 +12,7 @@ import pw.binom.db.ksqlite.SQLiteConnection
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких * Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е. * хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/ */
internal object Schema { object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1 const val CURRENT_VERSION: Int = 1
@@ -12,16 +12,9 @@ import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Тесты для [KsqliteContextStore] — точная копия * Тесты для [KsqliteContextStore]. Автономная фикстура: in-memory
* `KsqliteWorkingMemoryStoreTest` из `:storage-ksqlite`, с переименованием * SQLiteConnection + конструктор `KsqliteContextStore(connection)` — store сам
* типов (`WorkingMemoryStore` → `ContextStore`) и обновлённым пакетом для * прогоняет `Schema.migrate` в init, явный вызов не нужен.
* `Content` (`pw.binom.agentik.journal` — новый canonical, но структура
* та же).
*
* Тестовая фикстура: in-memory SQLiteConnection, [Schema.migrate] в @BeforeTest,
* `KsqliteContextStore(conn)` + ручной close в @AfterTest. Никакой внешней
* зависимости от `KsqliteStores` из `:storage-ksqlite` — этот модуль
* автономный.
*/ */
class KsqliteContextStoreTest { class KsqliteContextStoreTest {
@@ -31,7 +24,6 @@ class KsqliteContextStoreTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}") conn = SQLiteConnection.memory("ctx-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteContextStore(conn) store = KsqliteContextStore(conn)
} }
+1 -1
View File
@@ -194,7 +194,7 @@ suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста). `compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
### `ConversationStore` ### `MutableConversationStore`
```kotlin ```kotlin
suspend fun upsert(record: ConversationRecord) suspend fun upsert(record: ConversationRecord)
+6 -2
View File
@@ -6,7 +6,7 @@ kotlinx-io = "0.8.0"
ktor = "3.1.3" ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT" a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0" kaml = "0.104.0"
litert = "8" litert = "13"
sqldelight = "2.3.2" sqldelight = "2.3.2"
shadow = "8.3.5" shadow = "8.3.5"
jvector = "3.0.6" jvector = "3.0.6"
@@ -40,6 +40,9 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" } litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" } litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" } litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
# liteTool(liteToolRaw) DSL: типизированные LiteTool через @Serializable args.
# https://git.binom.pw/subochev/litert-kmp/src/branch/main/litert-tools-kotlinx-serialization
litert-tools-kotlinx-serialization = { module = "pw.binom.litert:litert-tools-kotlinx-serialization", version.ref = "litert" }
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver --- # --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
@@ -109,5 +112,6 @@ text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" } text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). --- # --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" } # KMP-артефакт (он же `kotlin-logging-jvm` существует отдельно как JVM-only build).
kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
@@ -1,27 +1,45 @@
package pw.binom.agentik.journal package pw.binom.agentik.journal
import kotlin.time.Instant import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/** /**
* CRUD по таблице `conversation`. * Read-only view of the `conversation` table (CRUD-операции находятся
* в [MutableConversationStore] и используются только внутри ChatAgent).
*
* Клиенты видят [ConversationStore] через [pw.binom.agentik.proto.Agent.conversationStore]
* (по аналогии с `journal` / `outbox`) и строят свой локальный кэш:
* - **seed** через [list] (snapshot страницы) или [listFlow] (cold-flow paging);
* - **live-refresh** через `outbox.agentEvents()` — Created / Deleted /
* Renamed / Touched.
*
* Запись в хранилище **не** делается клиентом — только команды
* `agent.createConversation / deleteConversation / renameConversation`.
*/ */
interface ConversationStore : AutoCloseable { interface ConversationStore : AutoCloseable {
/** Создать или обновить snapshot диалога. */
suspend fun upsert(record: ConversationRecord)
/** Диалог по id, или `null`. */ /** Диалог по id, или `null`. */
suspend fun get(id: String): ConversationRecord? suspend fun get(id: String): ConversationRecord?
/** Удалить диалог (вместе с его сообщениями и working memory). */
suspend fun delete(id: String): Boolean
/** Список диалогов, отсортированный по `updatedAt` DESC. */ /** Список диалогов, отсортированный по `updatedAt` DESC. */
suspend fun list(offset: Int, limit: Int): List<ConversationRecord> suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */ /**
suspend fun rename(id: String, title: String?): Instant? * Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */ * HTTP-импл — это лишние round-trip'ы; реализация может переопределить.
suspend fun touch(id: String, now: Instant) */
fun listFlow(offset: Int = 0, pageSize: Int = PAGE_SIZE): Flow<ConversationRecord> = flow {
var skip = offset
while (true) {
val page = list(skip, pageSize)
if (page.isEmpty()) break
page.forEach { emit(it) }
skip += page.size
}
}
companion object {
const val PAGE_SIZE: Int = 100
}
} }
@@ -18,6 +18,28 @@ interface JournalStore : AutoCloseable {
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord> suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/**
* Сколько сообщений в диалоге [conversationId] всего.
*
* O(1) на SQL-бэкендах (`SELECT COUNT(*) ... WHERE conversation_id = ?`),
* O(N) на in-memory (size простого list'а с фильтром по conversationId).
* Не зависит от cursor'а [after] — для total-размера диалога.
*/
suspend fun count(conversationId: String): Long
/**
* Сколько сообщений в диалоге [conversationId] создано **позже** [after]
* (строго `createdAt > after`, как и в [list]).
*
* O(1) на SQL-бэкендах, O(N) на in-memory. Полезно для:
* - UI badge "N новых сообщений" — клиент знает последний `lastSeen`,
* сервер говорит `count(convId, after=lastSeen)`;
* - пагинации без получения самих записей: знаем лимит последней страницы,
* надо понять "есть ли ещё";
* - compaction-метрик: «сколько turn'ов осталось после cutoff».
*/
suspend fun count(conversationId: String, after: Instant): Long
/** /**
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip * Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
* (по странице через `list()` пока не получит короткую страницу). Для * (по странице через `list()` пока не получит короткую страницу). Для
@@ -55,6 +55,14 @@ sealed interface MessageRecord {
override val id: String, override val id: String,
override val conversationId: String, override val conversationId: String,
val toolCallId: String, val toolCallId: String,
/**
* Имя тула, денормализованное из соответствующего `MessageRecord.ToolCall.toolName`.
* Денормализация экономна (одна строка в SQLite) и снимает с UI
* необходимость сопоставления `toolCallId → toolName`. `null` —
* безопасный backfill для записей до миграции или для сиротливых
* результатов без предшествующего `ToolCall`.
*/
val toolName: String? = null,
val result: String?, val result: String?,
override val createdAt: Instant, override val createdAt: Instant,
) : MessageRecord ) : MessageRecord
@@ -0,0 +1,21 @@
package pw.binom.agentik.journal
import kotlin.time.Instant
/**
* CRUD по таблице `conversation`.
*/
interface MutableConversationStore : ConversationStore {
/** Создать или обновить snapshot диалога. */
suspend fun upsert(record: ConversationRecord)
/** Удалить диалог (вместе с его сообщениями и working memory). */
suspend fun delete(id: String): Boolean
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
suspend fun rename(id: String, title: String?): Instant?
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
suspend fun touch(id: String, now: Instant)
}
@@ -14,4 +14,12 @@ package pw.binom.agentik.journal
*/ */
interface MutableJournalStore : JournalStore { interface MutableJournalStore : JournalStore {
suspend fun append(record: MessageRecord) suspend fun append(record: MessageRecord)
/**
* Удалить все сообщения диалога [conversationId]. Используется
* владельцем lifecycle диалога при его удалении (каскад из
* ChatAgent.deleteConversation). Append-only природа audit log'а
* не нарушается — это bulk-clear, а не редактирование.
*/
suspend fun clear(conversationId: String)
} }
+10 -6
View File
@@ -2,12 +2,10 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
} }
// KMP-реализация [MutableJournalStore] на `MutableList` + `Mutex` — для // KMP-реализация [MutableJournalStore] и [MutableConversationStore] на
// тестов, dev-режима, embedded-сценариев (Android core, CLI, in-process кэш // `MutableList`/`MutableMap` + `Mutex` — для тестов, dev-режима,
// в клиенте) и как образец для своей реализации. // embedded-сценариев (Android core, CLI, in-process кэш в клиенте) и как
// // образец для своей реализации.
// `list` фильтрует по `conversationId`+`createdAt>after` и сортирует
// по `createdAt ASC`. Paging — поверх отфильтрованного списка.
// //
// Зависимости: только `:journal-api`. Никакого I/O — pure in-memory. // Зависимости: только `:journal-api`. Никакого I/O — pure in-memory.
@@ -15,7 +13,13 @@ kotlin {
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64() linuxX64()
linuxArm64()
mingwX64() mingwX64()
sourceSets { sourceSets {
@@ -54,9 +54,17 @@ class InMemoryJournalStore : MutableJournalStore {
.toList() .toList()
} }
/** Сбросить кэш (например, когда диалог удалён). */ /** Удалить все записи диалога (каскад из ChatAgent.deleteConversation). */
suspend fun clear(): Unit = mutex.withLock { override suspend fun clear(conversationId: String): Unit = mutex.withLock {
records.clear() records.removeAll { it.conversationId == conversationId }
}
override suspend fun count(conversationId: String): Long = mutex.withLock {
records.count { it.conversationId == conversationId }.toLong()
}
override suspend fun count(conversationId: String, after: Instant): Long = mutex.withLock {
records.count { it.conversationId == conversationId && it.createdAt > after }.toLong()
} }
/** Сколько записей сейчас в кэше. Для тестов/диагностики. */ /** Сколько записей сейчас в кэше. Для тестов/диагностики. */
@@ -1,24 +1,35 @@
package pw.binom.agentik.storage.inmemory package pw.binom.agentik.journal.inmemory
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlin.time.Clock
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.MutableConversationStore
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Thread-safe Map-импл [ConversationStore]. * Thread-safe Map-импл [MutableConversationStore] для клиентских
* in-process кэшей (и тестов/dev-режима).
* *
* Использует `Mutex` для атомарности read-modify-write операций * Использует `Mutex` для атомарности read-modify-write операций
* (rename, touch) — иначе два параллельных `rename` могут потерять обновления * (rename, touch) — иначе два параллельных `rename` могут потерять обновления
* (lost-update race), что в SQLite невозможно из-за driver-level locking. * (lost-update race), что в SQLite невозможно из-за driver-level locking.
*
* **Сортировка**: `list()` сортирует по `updatedAt DESC`.
*
* **Типичный кэш-паттерн в клиенте** (см. `client/README.md`):
* ```
* val local = InMemoryMutableConversationStore()
* // seed: remote.listFlow → local.upsert
* // live-refresh: outbox.agentEvents → local.upsert/delete/rename/touch
* // UI: local.list(0, PAGE_SIZE)
* ```
*/ */
class InMemoryConversationStore( class InMemoryMutableConversationStore(
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) : ConversationStore { ) : MutableConversationStore {
private val byId: MutableMap<String, ConversationRecord> = mutableMapOf() private val byId = mutableMapOf<String, ConversationRecord>()
private val mutex = Mutex() private val mutex = Mutex()
override suspend fun upsert(record: ConversationRecord) { override suspend fun upsert(record: ConversationRecord) {
@@ -66,13 +66,96 @@ class InMemoryJournalStoreTest {
} }
@Test @Test
fun `clear empties the cache`() = runTest { fun `clear empties a conversation only`() = runTest {
val store = InMemoryJournalStore() val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z") val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "x", t0)) store.append(userMsg("m1", "c1", "x", t0))
store.append(userMsg("m2", "c2", "y", t0))
assertEquals(2, store.size())
store.clear("c1")
assertEquals(1, store.size()) assertEquals(1, store.size())
store.clear()
assertEquals(0, store.size())
assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty()) assertTrue(store.list("c1", Instant.DISTANT_PAST, 0, 100).isEmpty())
assertEquals(listOf("m2"), store.list("c2", Instant.DISTANT_PAST, 0, 100).map { it.id })
}
@Test
fun `count returns total per conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
assertEquals(0L, store.count("c1"))
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 1.seconds))
store.append(userMsg("m3", "c2", "c", t0 + 2.seconds))
assertEquals(2L, store.count("c1"))
assertEquals(1L, store.count("c2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun `count is unaffected by clear of another conversation`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..20) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
store.append(userMsg("n1", "c2", "y", t0))
assertEquals(20L, store.count("c1"))
assertEquals(1L, store.count("c2"))
store.clear("c1")
assertEquals(0L, store.count("c1"))
assertEquals(1L, store.count("c2"))
}
@Test
fun `count after cursor excludes earlier messages`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t0))
store.append(userMsg("m2", "c1", "b", t0 + 10.seconds))
store.append(userMsg("m3", "c1", "c", t0 + 20.seconds))
// strictly > t0
assertEquals(2L, store.count("c1", t0))
// strictly > t0+10s — only the last
assertEquals(1L, store.count("c1", t0 + 10.seconds))
// after last — empty
assertEquals(0L, store.count("c1", t0 + 20.seconds))
// distant past — all
assertEquals(3L, store.count("c1", Instant.DISTANT_PAST))
}
@Test
fun `count after cursor scopes to conversation`() = runTest {
val store = InMemoryJournalStore()
val t = Instant.parse("2026-09-21T10:00:00Z")
store.append(userMsg("m1", "c1", "a", t))
store.append(userMsg("m2", "c2", "b", t))
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("c1", future))
assertEquals(0L, store.count("c2", future))
assertEquals(1L, store.count("c1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("c2", Instant.DISTANT_PAST))
}
@Test
fun `count agrees with list size`() = runTest {
val store = InMemoryJournalStore()
val t0 = Instant.parse("2026-09-21T10:00:00Z")
for (i in 1..15) {
store.append(userMsg("m$i", "c1", "x", t0 + i.seconds))
}
assertEquals(
store.list("c1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("c1"),
)
val cutoff = t0 + 7.seconds
assertEquals(
store.list("c1", cutoff, 0, 1000).size.toLong(),
store.count("c1", cutoff),
)
} }
} }
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.inmemory package pw.binom.agentik.journal.inmemory
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import kotlin.test.Test import kotlin.test.Test
@@ -9,11 +9,11 @@ import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
class InMemoryConversationStoreTest { class InMemoryMutableConversationStoreTest {
@Test @Test
fun `upsert and get roundtrip preserves all fields`() = runTest { fun `upsert and get roundtrip preserves all fields`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val rec = ConversationRecord( val rec = ConversationRecord(
id = "c1", id = "c1",
title = "test", title = "test",
@@ -28,13 +28,13 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `get returns null for missing id`() = runTest { fun `get returns null for missing id`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
assertNull(store.get("nope")) assertNull(store.get("nope"))
} }
@Test @Test
fun `delete removes the record and returns true`() = runTest { fun `delete removes the record and returns true`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
store.upsert( store.upsert(
ConversationRecord( ConversationRecord(
"c1", null, false, "c1", null, false,
@@ -50,7 +50,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest { fun `list sorts by updatedAt DESC and respects offset+limit`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0)) store.upsert(ConversationRecord("c1", null, false, t0, t0))
store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S")))) store.upsert(ConversationRecord("c2", null, false, t0, t0.plus(kotlin.time.Duration.parse("PT60S"))))
@@ -69,7 +69,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `rename updates title and updatedAt returns new updatedAt`() = runTest { fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0)) store.upsert(ConversationRecord("c1", null, false, t0, t0))
@@ -84,7 +84,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `rename with null title clears it`() = runTest { fun `rename with null title clears it`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", "old", false, t0, t0)) store.upsert(ConversationRecord("c1", "old", false, t0, t0))
store.rename("c1", null) store.rename("c1", null)
@@ -93,13 +93,13 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `rename returns null for missing conversation`() = runTest { fun `rename returns null for missing conversation`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
assertNull(store.rename("nope", "x")) assertNull(store.rename("nope", "x"))
} }
@Test @Test
fun `touch bumps updatedAt without changing other fields`() = runTest { fun `touch bumps updatedAt without changing other fields`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z") val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0)) store.upsert(ConversationRecord("c1", "title", false, t0, t0))
@@ -112,7 +112,7 @@ class InMemoryConversationStoreTest {
@Test @Test
fun `close is idempotent and does nothing`() { fun `close is idempotent and does nothing`() {
val store = InMemoryConversationStore() val store = InMemoryMutableConversationStore()
store.close() store.close()
store.close() // должно быть no-op store.close() // должно быть no-op
} }
+1 -1
View File
@@ -9,7 +9,7 @@ plugins {
// собственных ksqlite-модулях. // собственных ksqlite-модулях.
// //
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled // Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (см. KDoc :storage-ksqlite). // на Linux (ksqlite не публикует macOS / iOS native артефакты на Maven Central).
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -14,31 +14,67 @@ import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [MutableJournalStore] (append-only audit log). * ksqlite-реализация [MutableJournalStore] (append-only audit log).
* *
* Структура — копия [pw.binom.agentik.storage.ksqlite.KsqliteMessageStore] * Единственный класс для message-таблицы. Используется напрямую через
* из `:storage-ksqlite`, но: * `:journal-ksqlite` зависимость; bundle'ом собирает
* - лежит в собственном модуле `:journal-ksqlite`; * `pw.binom.agentik.standalone.persistence.SqliteStores`.
* - реализует переименованный [MutableJournalStore] (раньше был *
* `MutableMessageStore`, теперь главный класс — `JournalStore` / * ## Lifecycle соединения
* `MutableJournalStore`); сам тип записи [MessageRecord] не *
* переименовывался. * Три формы конструктора с разной семантикой владения:
* - `KsqliteJournalStore(connection)` — внешнее соединение, store НЕ закрывает
* его в [close]. Для shared-connection bundles (`SqliteStores.assemble`),
* где один connection используется многими store'ами и закрывается bundle'ом.
* - `KsqliteJournalStore(path)` — открывает файловое соединение, закрывает
* его в [close].
* - `KsqliteJournalStore.memory(name)` — открывает in-memory соединение,
* закрывает его в [close].
*
* ## Миграция
*
* [Schema.migrate] прогоняется ВСЕГДА при конструировании — это idempotent
* (CREATE TABLE / INDEX IF NOT EXISTS), так что лишних эффектов нет ни в
* standalone-форме, ни в shared-connection bundle'е, где несколько store'ов
* прогоняют миграцию одной и той же схемы по очереди.
* *
* Prepared statements (insert / list / clear) препарируются один раз в * Prepared statements (insert / list / clear) препарируются один раз в
* конструкторе и закрываются в [close]. Без этого GC финалайзеры каждого * конструкторе и закрываются в [close] ДО закрытия owned connection. Без этого
* StmtHolder'а пытаются `sqlite3_finalize` stmt, чей parent connection уже * GC финалайзеры каждого StmtHolder'а пытаются `sqlite3_finalize` stmt, чей
* закрыт → SIGSEGV в `pthread_mutex_lock` (см. [pw.binom.db.ksqlite.StmtHolder]). * parent connection уже закрыт → SIGSEGV в `pthread_mutex_lock`
* (см. [pw.binom.db.ksqlite.StmtHolder]).
* *
* `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding * `payloadJson` хранит JSON-сериализованные kind-specific поля. encoding
* helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат * helpers (`encodeRecord` / `toMessageRecord` / `CallPayload` / ...) лежат
* в [MessageCodecs.kt] рядом. * в [MessageCodecs.kt] рядом.
*
* ВНИМАНИЕ: `:storage-ksqlite/KsqliteMessageStore.kt` остаётся на диске —
* это копия, не замена. Не удалять старый файл; миграция consumers'ов —
* отдельно.
*/ */
class KsqliteJournalStore internal constructor( class KsqliteJournalStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
) : MutableJournalStore { ) : MutableJournalStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`), где один
* connection используется многими store'ами и закрывается bundle'ом.
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -64,6 +100,16 @@ class KsqliteJournalStore internal constructor(
private val clearStmt: SQLitePreparedStatement = connection.prepare( private val clearStmt: SQLitePreparedStatement = connection.prepare(
"DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?" "DELETE FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
) )
private val countAllStmt: SQLitePreparedStatement = connection.prepare(
"SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE} WHERE ${Schema.COL_CONVERSATION_ID} = ?"
)
private val countAfterStmt: SQLitePreparedStatement = connection.prepare(
"""
SELECT COUNT(*) FROM ${Schema.TABLE_MESSAGE}
WHERE ${Schema.COL_CONVERSATION_ID} = ?
AND ${Schema.COL_CREATED_AT} > ?
""".trimIndent()
)
override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) { override suspend fun append(record: MessageRecord): Unit = withContext(Dispatchers.Default) {
val (kind, payload) = encodeRecord(record) val (kind, payload) = encodeRecord(record)
@@ -94,13 +140,15 @@ class KsqliteJournalStore internal constructor(
listStmt.bindLong(4, offset.toLong()) listStmt.bindLong(4, offset.toLong())
val out = mutableListOf<MessageRecord>() val out = mutableListOf<MessageRecord>()
listStmt.executeQuery().use { rs -> listStmt.executeQuery().use { rs ->
while (rs.next()) out.add(rs.toMessageRecord(json)) while (rs.next()) {
out.add(rs.toMessageRecord(json))
}
} }
out out
} }
} }
internal suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) { override suspend fun clear(conversationId: String): Unit = withContext(Dispatchers.Default) {
mutex.withLock { mutex.withLock {
clearStmt.reset() clearStmt.reset()
clearStmt.clearBindings() clearStmt.clearBindings()
@@ -109,9 +157,52 @@ class KsqliteJournalStore internal constructor(
} }
} }
override suspend fun count(conversationId: String): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAllStmt.reset()
countAllStmt.clearBindings()
countAllStmt.bindText(1, conversationId)
countAllStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override suspend fun count(conversationId: String, after: Instant): Long = withContext(Dispatchers.Default) {
mutex.withLock {
countAfterStmt.reset()
countAfterStmt.clearBindings()
countAfterStmt.bindText(1, conversationId)
countAfterStmt.bindLong(2, after.toEpochMilliseconds())
countAfterStmt.executeQuery().use { rs ->
check(rs.next()) { "COUNT(*) must return at least one row" }
(rs.getLong(0) ?: 0L)
}
}
}
override fun close() { override fun close() {
insertStmt.close() insertStmt.close()
listStmt.close() listStmt.close()
clearStmt.close() clearStmt.close()
countAllStmt.close()
countAfterStmt.close()
if (ownsConnection) {
connection.close()
}
}
companion object {
/**
* Открывает in-memory соединение через [SQLiteConnection.memory] и
* берёт на себя его закрытие в [close]. Удобно для тестов и ephemeral
* runtime.
*/
fun memory(name: String? = null) =
KsqliteJournalStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
} }
@@ -1,9 +1,9 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.journal.ConversationRecord import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -11,15 +11,52 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* ksqlite-реализация [ConversationStore]. Схема таблицы `conversation` живёт * ksqlite-реализация [MutableConversationStore]. Схема таблицы `conversation` живёт
* в [Schema] (миграция через PRAGMA user_version) — этот класс только * в [Schema] (миграция через PRAGMA user_version) — этот класс только
* готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`. * готовит и выполняет SQL, ссылаясь на `Schema.COL_*` / `Schema.TABLE_*`.
*
* Каскадное удаление связанных данных (message + working_memory) делает
* владелец lifecycle диалога (см. ChatAgent.deleteConversation) — этот
* store знает только про свою таблицу.
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична [KsqliteJournalStore]:
* - `KsqliteMutableConversationStore(connection)` — внешнее соединение,
* store НЕ закрывает его в [close] (используется shared-connection
* bundle'ом `SqliteStores.assemble`).
* - `KsqliteMutableConversationStore(path)` — открывает файловое соединение,
* закрывает его в [close].
* - `KsqliteMutableConversationStore.memory(name)` — in-memory, закрывает
* в [close].
*/ */
class KsqliteConversationStore( class KsqliteMutableConversationStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val messageStore: KsqliteMessageStore? = null, private val ownsConnection: Boolean,
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null, ) : MutableConversationStore {
) : ConversationStore {
/**
* Открывает файловое соединение через [SQLiteConnection.open] и берёт на
* себя его закрытие в [close]. Для standalone использования, когда у
* store'а нет bundle'а-владельца connection'а.
*/
constructor(path: String) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
)
/**
* Внешнее соединение — store НЕ закрывает его в [close]. Для
* shared-connection bundles (`SqliteStores.assemble`).
*/
constructor(connection: SQLiteConnection) : this(
connection = connection,
ownsConnection = false,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
@@ -125,8 +162,6 @@ class KsqliteConversationStore(
// Проверяем существование через raw query, НЕ через get() — get() тоже // Проверяем существование через raw query, НЕ через get() — get() тоже
// берёт mutex (не реентрант), что привело бы к deadlock. // берёт mutex (не реентрант), что привело бы к deadlock.
if (!execExists(id)) return@withContext false if (!execExists(id)) return@withContext false
messageStore?.clear(id)
workingMemoryStore?.clear(id)
deleteStmt.reset() deleteStmt.reset()
deleteStmt.clearBindings() deleteStmt.clearBindings()
deleteStmt.bindText(1, id) deleteStmt.bindText(1, id)
@@ -188,6 +223,15 @@ class KsqliteConversationStore(
renameStmt.close() renameStmt.close()
renameUpdatedAtStmt.close() renameUpdatedAtStmt.close()
touchStmt.close() touchStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null): KsqliteMutableConversationStore =
KsqliteMutableConversationStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
)
} }
private fun execExists(id: String): Boolean { private fun execExists(id: String): Boolean {
@@ -10,10 +10,8 @@ import kotlin.time.Instant
/** /**
* Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite. * Кодирование [MessageRecord] → пара (kind, payloadJson) для SQLite.
* *
* Копия `MessageCodecs.kt` из `:storage-ksqlite` — `internal` helpers * `internal` helpers живут рядом со своим store'ом (в `:journal-ksqlite`),
* нельзя переиспользовать между модулями, поэтому в каждом backend свой набор. * не в каком-то внешнем общем модуле.
* Чтобы избежать дрейфа при изменении формата payload'а, оба набора синхронизируются
* через эти data class'ы (CallPayload/ResultPayload/ErrorPayload).
*/ */
internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) { internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload( is MessageRecord.UserMessage -> "user" to encodeBodyPayload(
@@ -30,7 +28,7 @@ internal fun encodeRecord(record: MessageRecord): Pair<String, String> = when (r
) )
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString( is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
ResultPayload.serializer(), ResultPayload.serializer(),
ResultPayload(toolCallId = record.toolCallId, result = record.result), ResultPayload(toolCallId = record.toolCallId, toolName = record.toolName, result = record.result),
) )
is MessageRecord.Error -> "error" to Json.encodeToString( is MessageRecord.Error -> "error" to Json.encodeToString(
ErrorPayload.serializer(), ErrorPayload.serializer(),
@@ -59,7 +57,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
} }
"tool_result" -> { "tool_result" -> {
val p = Json.decodeFromString(ResultPayload.serializer(), payload) val p = Json.decodeFromString(ResultPayload.serializer(), payload)
MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, result = p.result, createdAt = createdAt) MessageRecord.ToolResult(id = id, conversationId = convId, toolCallId = p.toolCallId, toolName = p.toolName, result = p.result, createdAt = createdAt)
} }
"error" -> { "error" -> {
val p = Json.decodeFromString(ErrorPayload.serializer(), payload) val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
@@ -72,8 +70,18 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
internal data class CallPayload(val name: String, val title: String?, val argsJson: String) internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
/**
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
* без поля десериализуются как `null`.
*/
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
internal data class ResultPayload(val toolCallId: String, val result: String?) internal data class ResultPayload(
val toolCallId: String,
val toolName: String? = null,
val result: String?,
)
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
internal data class ErrorPayload(val message: String, val code: String?) internal data class ErrorPayload(val message: String, val code: String?)
@@ -5,32 +5,51 @@ import pw.binom.db.ksqlite.SQLiteConnection
/** /**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`. * Имена таблиц/колонок/индексов для ksqlite-бэкенда `:journal-api`.
* *
* Минимум — только то, что относится к `message` (append-only audit log). * Владеет двумя таблицами:
* Остальные таблицы агента (`conversation`, `working_memory`, `reflection`) * - `conversation` — реестр диалогов агента (см. ConversationRecord);
* живут в других ksqlite-модулях. * - `message` — append-only audit log сообщений диалогов.
*
* `working_memory` и `reflection` живут в других ksqlite-модулях.
* *
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких * Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е. * хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/ */
internal object Schema { object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */ /** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1 const val CURRENT_VERSION: Int = 1
// ───── Таблица ───── // ───── Таблицы ─────
const val TABLE_CONVERSATION = "conversation"
const val TABLE_MESSAGE = "message" const val TABLE_MESSAGE = "message"
// ───── Колонки ───── // ───── Колонки conversation ─────
const val COL_ID = "id" const val COL_ID = "id"
const val COL_TITLE = "title"
const val COL_IS_TEMPORAL = "is_temporal"
const val COL_CREATED_AT = "created_at"
const val COL_UPDATED_AT = "updated_at"
// ───── Колонки message ─────
const val COL_CONVERSATION_ID = "conversation_id" const val COL_CONVERSATION_ID = "conversation_id"
const val COL_KIND = "kind" const val COL_KIND = "kind"
const val COL_PAYLOAD_JSON = "payload_json" const val COL_PAYLOAD_JSON = "payload_json"
const val COL_CREATED_AT = "created_at"
// ───── Индексы ───── // ───── Индексы ─────
const val IDX_CONV_UPDATED = "idx_conv_updated"
const val IDX_MSG_CONV = "idx_msg_conv" const val IDX_MSG_CONV = "idx_msg_conv"
private val v1Ddl = """ private val v1ConversationDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_CONVERSATION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_TITLE TEXT,
$COL_IS_TEMPORAL INTEGER NOT NULL DEFAULT 0,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_UPDATED_AT INTEGER NOT NULL
);
"""
private val v1MessageDdl = """
CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE ( CREATE TABLE IF NOT EXISTS $TABLE_MESSAGE (
$COL_ID TEXT NOT NULL PRIMARY KEY, $COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT NOT NULL, $COL_CONVERSATION_ID TEXT NOT NULL,
@@ -38,54 +57,43 @@ internal object Schema {
$COL_PAYLOAD_JSON TEXT NOT NULL, $COL_PAYLOAD_JSON TEXT NOT NULL,
$COL_CREATED_AT INTEGER NOT NULL $COL_CREATED_AT INTEGER NOT NULL
); );
""".trimIndent() """
private val v1IndexesDdl = """ private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_CONV_UPDATED
ON $TABLE_CONVERSATION($COL_UPDATED_AT DESC);
-- Главный hot-path индекс для list/сообщений: фильтр по conv +
-- сортировка по created_at (используется list(), cascade-clear, etc.)
CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV CREATE INDEX IF NOT EXISTS $IDX_MSG_CONV
ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT); ON $TABLE_MESSAGE($COL_CONVERSATION_ID, $COL_CREATED_AT);
""".trimIndent() """
/** /**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД. * Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
* *
* Версия хранится в `PRAGMA user_version` (стандартный SQLite-механизм, * Гарантии:
* 32-bit int в заголовке БД — без своей таблицы). Каждая миграция — * - идемпотентность: `CREATE TABLE/INDEX IF NOT EXISTS` — безопасно на
* блок DDL под номером `fromV+1`, выполняется в транзакции. Если миграция * уже-мигрированной БД;
* упадёт посередине — `ROLLBACK` оставит БД на предыдущей версии. * - атомарность: каждая миграция в BEGIN/COMMIT — упал посреди →
* ROLLBACK оставит БД консистентной.
* *
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op. **NOTE**: в сплит-мире (4 ksqlite-модуля, каждый владеет своей таблицей)
* user_version как gate перестал работать — два модуля ставят его в 1,
* второй вызов short-circuit'ит. Поэтому migrate() просто прогоняет DDL
* idempotently; координация multi-module миграций — ответственность
* вызывающего (см. `pw.binom.agentik.standalone.persistence.SqliteStores`).
*/ */
fun migrate(conn: SQLiteConnection) { fun migrate(conn: SQLiteConnection) {
val current = readUserVersion(conn)
if (current >= CURRENT_VERSION) return
conn.exec("BEGIN") conn.exec("BEGIN")
try { try {
if (current < 1) { conn.exec(v1ConversationDdl)
conn.exec(v1Ddl) conn.exec(v1MessageDdl)
conn.exec(v1IndexesDdl) conn.exec(v1IndexesDdl)
}
// future: if (current < 2) { conn.exec(v2Ddl) }
writeUserVersion(conn, CURRENT_VERSION)
conn.exec("COMMIT") conn.exec("COMMIT")
} catch (t: Throwable) { } catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") } runCatching { conn.exec("ROLLBACK") }
throw t throw t
} }
} }
private fun readUserVersion(conn: SQLiteConnection): Int {
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
}
}
return 0
}
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
// SQLite PRAGMA с literal-аргументом нельзя параметризовать через `?`,
// поэтому собираем SQL строкой (значение контролируемое, не user input).
conn.exec("PRAGMA user_version = $version")
}
} }
@@ -13,15 +13,9 @@ import kotlin.test.assertEquals
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Тесты для [KsqliteJournalStore] — точная копия * Тесты для [KsqliteJournalStore]. Автономная фикстура: in-memory
* `KsqliteMessageStoreTest` из `:storage-ksqlite`, с переименованием типов * SQLiteConnection + конструктор `KsqliteJournalStore(connection)` — store сам
* (`MessageStore` → `JournalStore`) и автономной фикстурой (in-memory * прогоняет `Schema.migrate` в init, явный вызов не нужен.
* SQLiteConnection + Schema.migrate).
*
* Тест `testClearRemovesByConversation` из оригинала использовал
* `stores.conversations.delete(...)` (cascade через `KsqliteStores`) — здесь
* он заменён на прямой вызов `store.clear(...)`, потому что `:journal-ksqlite`
* автономен и не знает про ConversationStore.
*/ */
class KsqliteJournalStoreTest { class KsqliteJournalStoreTest {
@@ -31,7 +25,6 @@ class KsqliteJournalStoreTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}") conn = SQLiteConnection.memory("journal-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn)
store = KsqliteJournalStore(conn) store = KsqliteJournalStore(conn)
} }
@@ -103,4 +96,93 @@ class KsqliteJournalStoreTest {
assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList()) assertEquals(emptyList(), store.listFlow("conv1", Instant.DISTANT_PAST).toList())
assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size) assertEquals(1, store.listFlow("conv2", Instant.DISTANT_PAST).toList().size)
} }
@Test
fun testCountReturnsTotalForConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
assertEquals(0L, store.count("conv1"))
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t, null))
store.append(MessageRecord.UserMessage("m3", "conv2", listOf(Content.Text("c")), t, null))
assertEquals(2L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("missing"))
}
@Test
fun testCountIsolatedFromOtherConversations() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
// Bulk insert into conv1, single into conv2.
for (i in 1..50) {
store.append(MessageRecord.UserMessage("m$i", "conv1", listOf(Content.Text("x$i")), t, null))
}
store.append(MessageRecord.UserMessage("n1", "conv2", listOf(Content.Text("only")), t, null))
assertEquals(50L, store.count("conv1"))
assertEquals(1L, store.count("conv2"))
assertEquals(0L, store.count("never-existed"))
}
@Test
fun testCountAfterFiltersStrictly() = runTest {
val t1 = Instant.parse("2026-09-15T10:01:00Z")
val t2 = Instant.parse("2026-09-15T10:02:00Z")
val t3 = Instant.parse("2026-09-15T10:03:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t1, null))
store.append(MessageRecord.UserMessage("m2", "conv1", listOf(Content.Text("b")), t2, null))
store.append(MessageRecord.UserMessage("m3", "conv1", listOf(Content.Text("c")), t3, null))
// Strictly > — t1 is not counted when after=t1.
assertEquals(2L, store.count("conv1", t1))
// Strictly > — t2 IS counted (only t3 after).
assertEquals(1L, store.count("conv1", t2))
// After last — empty.
assertEquals(0L, store.count("conv1", t3))
// Distant past — all three.
assertEquals(3L, store.count("conv1", Instant.DISTANT_PAST))
}
@Test
fun testCountAfterIsolatesByConversation() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z")
store.append(MessageRecord.UserMessage("m1", "conv1", listOf(Content.Text("a")), t, null))
store.append(MessageRecord.UserMessage("m2", "conv2", listOf(Content.Text("b")), t, null))
// Cursor that excludes everything — both conversations read 0.
val future = Instant.parse("2099-01-01T00:00:00Z")
assertEquals(0L, store.count("conv1", future))
assertEquals(0L, store.count("conv2", future))
// Cursor that includes everything — only conv1's message matches its conversation.
assertEquals(1L, store.count("conv1", Instant.DISTANT_PAST))
assertEquals(1L, store.count("conv2", Instant.DISTANT_PAST))
}
@Test
fun testCountAgreesWithListSize() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..10) {
store.append(
MessageRecord.UserMessage(
id = "m$i",
conversationId = "conv1",
content = listOf(Content.Text("x$i")),
createdAt = t0 + kotlin.time.Duration.parse("PT${i}S"),
context = null,
),
)
}
// count === list(..., 0, +∞).size
assertEquals(
store.list("conv1", Instant.DISTANT_PAST, 0, 1000).size.toLong(),
store.count("conv1"),
)
// count(after=t5) === list(..., after=t5, 0, +∞).size
val cutoff = t0 + kotlin.time.Duration.parse("PT5S")
assertEquals(
store.list("conv1", cutoff, 0, 1000).size.toLong(),
store.count("conv1", cutoff),
)
}
} }
@@ -0,0 +1,146 @@
package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Тесты для [KsqliteMutableConversationStore]. Автономная фикстура —
* `SQLiteConnection.memory(...)` + конструктор `KsqliteMutableConversationStore(connection)`.
* Store сам прогоняет `Schema.migrate` в init, явный вызов не нужен.
*/
class KsqliteMutableConversationStoreTest {
private lateinit var conn: SQLiteConnection
private lateinit var store: KsqliteMutableConversationStore
@BeforeTest
fun setup() {
conn = SQLiteConnection.memory("conv-${kotlin.random.Random.nextLong()}")
store = KsqliteMutableConversationStore(conn)
}
@AfterTest
fun tearDown() {
store.close()
conn.close()
}
private fun rec(id: String, title: String? = null, ts: Instant = Instant.parse("2026-09-15T10:00:00Z")) =
ConversationRecord(id, title, false, ts, ts)
@Test
fun testUpsertAndGetRoundtrip() = runTest {
store.upsert(rec("c1", "test"))
assertEquals(rec("c1", "test"), store.get("c1"))
}
@Test
fun testGetReturnsNullForMissing() = runTest {
assertNull(store.get("nope"))
}
@Test
fun testDeleteRemovesAndReturnsTrue() = runTest {
store.upsert(rec("c1"))
assertTrue(store.delete("c1"))
assertNull(store.get("c1"))
assertEquals(false, store.delete("c1"))
}
@Test
fun testListSortsByUpdatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(rec("c1", ts = t0))
store.upsert(rec("c2", ts = t0))
store.upsert(rec("c3", ts = t0))
store.upsert(rec("c4", ts = t0))
store.touch("c2", t0 + Duration.parse("PT60S"))
store.touch("c3", t0 + Duration.parse("PT120S"))
store.touch("c4", t0 + Duration.parse("PT180S"))
val page = store.list(offset = 0, limit = 4)
assertEquals(listOf("c4", "c3", "c2", "c1"), page.map { it.id })
}
@Test
fun testListRespectsOffsetAndLimit() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
for (i in 1..5) store.upsert(rec("c$i", ts = t0 + Duration.parse("PT${i}S")))
val p0 = store.list(offset = 0, limit = 2)
assertEquals(2, p0.size)
val p2 = store.list(offset = 4, limit = 2)
assertEquals(1, p2.size)
}
@Test
fun testRenameUpdatesTitleAndUpdatedAt() = runTest {
store.upsert(rec("c1"))
val newTs = store.rename("c1", "new title")
assertNotNull(newTs)
assertEquals("new title", store.get("c1")?.title)
}
@Test
fun testRenameWithNullClearsTitle() = runTest {
store.upsert(rec("c1", "old"))
store.rename("c1", null)
assertNull(store.get("c1")?.title)
}
@Test
fun testRenameReturnsNullForMissing() = runTest {
assertNull(store.rename("nope", "x"))
}
@Test
fun testTouchUpdatesUpdatedAtOnly() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z")
val t1 = Instant.parse("2026-09-15T10:01:00Z")
store.upsert(ConversationRecord("c1", "title", false, t0, t0))
store.touch("c1", t1)
val got = store.get("c1")
assertEquals("title", got?.title)
assertEquals(t1, got?.updatedAt)
assertEquals(t0, got?.createdAt)
}
@Test
fun testMemoryFactoryAutoMigratesSchema() = runTest {
// Smoke-test: .memory() companion-фабрика должна прогнать Schema.migrate()
// автоматически. Если бы миграция не сработала — storePreparedStatement'ы
// упали бы на `prepare failed: no such table: conversation` ещё в конструкторе.
val owned = KsqliteMutableConversationStore.memory("conv-auto-${kotlin.random.Random.nextLong()}")
try {
owned.upsert(rec("c1", "hello"))
assertEquals("hello", owned.get("c1")?.title)
} finally {
owned.close()
}
}
@Test
fun testExternalConnectionConstructorAlsoMigrates() = runTest {
// Внешний конструктор `(connection)` ТОЖЕ мигрирует (Schema.migrate idempotent).
// Caller может не звать Schema.migrate перед конструктором.
val externalConn = SQLiteConnection.memory("conv-external-${kotlin.random.Random.nextLong()}")
val s = KsqliteMutableConversationStore(externalConn)
try {
s.upsert(rec("c1"))
assertNotNull(s.get("c1"))
} finally {
s.close()
externalConn.close()
}
}
}
@@ -1,26 +1,31 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.journal.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.journal.Content
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.MessageRecord
import pw.binom.db.ksqlite.SQLiteConnection import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* Тесты на Schema.migrate(): * Тесты на Schema.migrate() в `:journal-ksqlite`:
* - fresh DB → создаются все 4 таблицы + индексы + user_version = CURRENT_VERSION; * - fresh DB → создаются `conversation` + `message` + индексы
* - уже мигрированная БД → migrate() идемпотентен (no-op, не падает на * (`idx_conv_updated`, `idx_msg_conv`);
* повторных CREATE); * - уже мигрированная БД → migrate() идемпотентен (no-op);
* - DB, открытая напрямую через SQLiteConnection (минуя KsqliteStores), * - DB, открытая напрямую через SQLiteConnection (минуя SqliteStores),
* migrate() приводит её в боевое состояние. * migrate() приводит её в боевое состояние;
* - `idx_msg_conv` покрывает обе колонки — без этого list()/cascade-clear
* делают full-scan по message.
* *
* Также проверяем что наличие индекса idx_msg_conv (conversation_id + * `working_memory` тестируется в `pw.binom.agentik.context.ksqlite`; `reflection` —
* created_at) — обязательный hot-path для list()/cascade-delete. * в `pw.binom.agentik.reflection.ksqlite`.
*/ */
class SchemaMigrationTest { class SchemaMigrationTest {
@Test @Test
fun `fresh DB gets all tables indexes and CURRENT_VERSION`() = runTest { fun `fresh DB gets conversation and message tables and indexes`() = runTest {
val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-fresh-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -28,8 +33,6 @@ class SchemaMigrationTest {
for (table in listOf( for (table in listOf(
Schema.TABLE_CONVERSATION, Schema.TABLE_CONVERSATION,
Schema.TABLE_MESSAGE, Schema.TABLE_MESSAGE,
Schema.TABLE_WORKING_MEMORY,
Schema.TABLE_REFLECTION,
)) { )) {
assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()") assertTrue(tableExists(conn, table), "table '$table' should exist after migrate()")
} }
@@ -37,15 +40,9 @@ class SchemaMigrationTest {
for (index in listOf( for (index in listOf(
Schema.IDX_CONV_UPDATED, Schema.IDX_CONV_UPDATED,
Schema.IDX_MSG_CONV, Schema.IDX_MSG_CONV,
Schema.IDX_WM_UNIQUE,
Schema.IDX_WM_CONV,
Schema.IDX_REFLECTION_CREATED,
Schema.IDX_REFLECTION_CONV,
)) { )) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()") assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
} }
assertEquals(Schema.CURRENT_VERSION, readUserVersion(conn))
} finally { } finally {
conn.close() conn.close()
} }
@@ -56,66 +53,50 @@ class SchemaMigrationTest {
val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idem-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
val versionAfterFirst = readUserVersion(conn) // повторный вызов не должен ни упасть, ни пересоздать таблицы
// (CREATE IF NOT EXISTS — no-op)
// повторный вызов не должен ни упасть, ни изменить версию, ни
// пересоздать таблицы/индексы (CREATE IF NOT EXISTS — no-op)
Schema.migrate(conn) Schema.migrate(conn)
assertEquals(versionAfterFirst, readUserVersion(conn)) Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_CONVERSATION))
assertTrue(tableExists(conn, Schema.TABLE_MESSAGE))
} finally { } finally {
conn.close() conn.close()
} }
} }
@Test @Test
fun `raw SQLiteConnection plus migrate gives working bundle`() = runTest { fun `raw SQLiteConnection plus migrate gives working stores`() = runTest {
// Имитируем сценарий: существующая БД без schema, открываем через
// ksqlite и прогоняем migrate руками (тот же путь, что в
// KsqliteStores.open, но без зависимости от фабрики).
val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-bundle-${kotlin.random.Random.nextLong()}")
Schema.migrate(conn) Schema.migrate(conn)
// Сборка bundle через internal-конструктор — KsqliteStores primary val convStore = KsqliteMutableConversationStore(conn)
// constructor internal, тест в том же модуле и может его звать. val msgStore = KsqliteJournalStore(conn)
val stores = KsqliteStores(
connection = conn,
conversations = KsqliteConversationStore(conn),
messages = KsqliteMessageStore(conn),
workingMemory = KsqliteWorkingMemoryStore(conn),
reflections = KsqliteReflectionStore(conn),
)
try { try {
// bundle работает end-to-end — conversation upsert + message append + convStore.upsert(
// list. Никаких "no such table" или подобного. ConversationRecord(
stores.conversations.upsert(
pw.binom.agentik.journal.ConversationRecord(
id = "c1", title = "t", isTemporal = false, id = "c1", title = "t", isTemporal = false,
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"), updatedAt = kotlin.time.Instant.parse("2026-09-15T10:00:00Z"),
) )
) )
stores.messages.append( msgStore.append(
pw.binom.agentik.journal.MessageRecord.UserMessage( MessageRecord.UserMessage(
id = "m1", conversationId = "c1", id = "m1", conversationId = "c1",
content = listOf(pw.binom.agentik.journal.Content.Text("hi")), content = listOf(Content.Text("hi")),
createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"), createdAt = kotlin.time.Instant.parse("2026-09-15T10:00:01Z"),
) )
) )
val got = stores.messages.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10) val got = msgStore.list("c1", kotlin.time.Instant.DISTANT_PAST, offset = 0, limit = 10)
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals("m1", got[0].id) assertEquals("m1", got[0].id)
} finally { } finally {
// Закрываем store'ы → они закроют свои pre-prepared statements convStore.close()
// (StmtHolder.finalize увидит isOpen == false и не полезет в msgStore.close()
// нативный sqlite3_finalize с уже-разрушенным db mutex).
stores.close()
} }
} }
@Test @Test
fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest { fun `idx_msg_conv covers conversation_id and created_at columns`() = runTest {
// Проверяем что индекс действительно покрывает обе колонки — без
// этого list()/cascade-delete будут делать full-scan по message.
val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}") val conn = SQLiteConnection.memory("mig-idx-${kotlin.random.Random.nextLong()}")
try { try {
Schema.migrate(conn) Schema.migrate(conn)
@@ -158,16 +139,4 @@ class SchemaMigrationTest {
} }
return cols return cols
} }
private fun readUserVersion(conn: SQLiteConnection): Int {
var version = 0
conn.prepare("PRAGMA user_version").use { stmt ->
stmt.executeQuery().use { rs ->
if (rs.next()) {
version = (rs.getLong(0) ?: 0L).toInt()
}
}
}
return version
}
} }
+16 -7
View File
@@ -2,9 +2,8 @@
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class) // ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах. // — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
// //
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm // KMP (jvm + все native — аналогично :agent-toolsets), потому что контракт
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain, // `:litert-api` уже KMP и других JVM-only зависимостей тут нет.
// доступные JVM target'у.
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) @file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins { plugins {
@@ -15,6 +14,14 @@ kotlin {
jvmToolchain(21) jvmToolchain(21)
jvm() jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
@@ -24,16 +31,18 @@ kotlin {
api(project(":context-api")) api(project(":context-api"))
api(project(":skills")) api(project(":skills"))
api(libs.litert.api) api(libs.litert.api)
// KotlinLogging — KMP (Gradle module metadata правильно выбирает
// jvm/native variant из общего артефакта).
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
} implementation(libs.kotlinx.coroutines.test)
jvmMain.dependencies { implementation(libs.litert.tools.kotlinx.serialization)
// mu.KotlinLogging — JVM-only, для SkillMiner'а implementation(project(":memory-md"))
implementation(libs.kotlin.logging)
} }
} }
} }
@@ -29,7 +29,7 @@ class LlmReflector(
private val llm: LiteLlm, private val llm: LiteLlm,
val maxTurns: Int = 6, val maxTurns: Int = 6,
private val maxTokens: Int = 512, private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) { ) {
/** /**
@@ -0,0 +1,127 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools.memory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -8,7 +8,7 @@ import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.standalone.agent.FakeLiteLlm import pw.binom.agentik.llm.tools.FakeLiteLlm
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools.memory
import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision import pw.binom.agentik.memory.MemoryReviewDecision
+56
View File
@@ -0,0 +1,56 @@
# :mcp-bridge
Мост между протоколом [MCP](https://modelcontextprotocol.io/) (Model Context Protocol)
и `MutableAgent` из `:agent-api`. Превращает удалённые MCP-серверы в набор
тулов, доступных агенту через стандартный install-механизм.
## Что это и зачем
`McpRegistry` умеет подключаться к N MCP-серверам, опрашивать их
`tools/list` и держать в памяти именованные тулы (`NamedTool`). Но
`McpRegistry` сам по себе ничего не знает про агента — это просто реестр
плюс JSON-RPC-клиент.
`McpBridgeComponent` — адаптер: реализует `pw.binom.agentik.agent.Component`
и при `install(MutableAgent)` добавляет в `toolProviders` провайдер, который
возвращает текущий снимок тулов из реестра. При `uninstall` — снимает.
## Использование
```kotlin
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
val agent = ChatAgent(...)
.install(McpBridgeComponent(mcpRegistry))
```
После `install` MCP-тулы доступны агенту через стандартный
`toolsetDispatch.baseDispatcher` — вызываются точно так же, как и встроенные
(skill / memory / enable_toolset). Никаких особых путей.
На каждом tool-call `collectTools()` (внутри ChatAgent) перебирает все
`toolProviders` и собирает актуальный список — добавление/удаление компонента
видно немедленно, без рестарта агента.
## Структура
- `McpRegistry` — JSON-RPC клиент, поддерживает несколько MCP-серверов,
параллельный опрос `tools/list` при старте, reconnect.
- `McpBridgeComponent` — адаптер к `:agent-api`. Реализует `Component`;
держит ссылку на `McpRegistry`; на `install` пушит `McpToolProvider`
в `MutableAgent.toolProviders`, на `uninstall` снимает.
- `McpToolProvider` — внутренний `ToolProvider`, возвращает
`registry.namedTools`. Пустой по сути адаптер, нужен только чтобы дать
имя для удаления (removeAll-логика).
## Зависимости
- `:agent-api` — `MutableAgent`, `Component`, `ToolProvider`.
- `:agent-toolsets` — `NamedTool`, `LiteTool`.
- `:outbox-api` — для live-событий (connection-loss / reconnect notifications).
## НЕ включено
- HTTP/SSE транспорт к MCP-серверам (только stdio/JSON-RPC сейчас; HTTP-вариант
доделывается).
- Конвертация `McpResource` → `MemoryNote` (отдельная фича, не реализована).
- Авторизация OAuth (MCP 2025-06-15 draft) — пока нет.
+5 -3
View File
@@ -7,8 +7,8 @@
// или :agentik-tui когда те снова включатся. // или :agentik-tui когда те снова включатся.
// //
// Зависимости: // Зависимости:
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель) // - litert.api для LiteTool контракта (в v9 у LiteTool появилось поле name,
// - litert.api для LiteTool контракта // NamedTool-обёртка из :agent-toolsets больше не нужна)
// - MCP SDK (JVM-only) // - MCP SDK (JVM-only)
// - Ktor client (для StreamableHttpClientTransport) // - Ktor client (для StreamableHttpClientTransport)
// - kotlinx-serialization для парсинга конфига // - kotlinx-serialization для парсинга конфига
@@ -22,7 +22,9 @@ kotlin {
} }
dependencies { dependencies {
implementation(project(":agent-toolsets")) // :agent-api — отсюда Component / ToolProvider; McpBridgeComponent
// реализует Component и подсовывает MCP-тулы через ToolProvider.
api(project(":agent-api"))
api(libs.litert.api) api(libs.litert.api)
@@ -0,0 +1,61 @@
package pw.binom.agentik.mcp.bridge
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.ToolProvider
import pw.binom.litert.LiteTool
/**
* [Component], встраивающий [McpRegistry] в [MutableAgent] через [ToolProvider].
*
* При [install] добавляет один [ToolProvider] в `agent.toolProviders` —
* он возвращает `registry.allTools` (все MCP-тулы со всех подключённых
* серверов, с префиксом `serverName__` чтобы избежать коллизий).
*
* При [uninstall] убирает свой [ToolProvider] обратно. Повторный `uninstall`
* — no-op. **Не** закрывает [McpRegistry] — за это отвечает host
* (обычно shutdown hook в `Main.kt`).
*
* Типичное использование:
* ```
* val registry = McpRegistry.fromConfig(config.mcp)
* val agent = ChatAgent(...).install(McpBridgeComponent(registry))
* // ...
* Runtime.getRuntime().addShutdownHook(Thread { registry.close() })
* ```
*
* Пока встраивается только в `:standalone` через `:mcp-bridge` —
* `:agentik-cli` / `:agentik-tui` (когда снова включатся) получат эту же
* механику без изменений в [ChatAgent] constructor'е.
*/
class McpBridgeComponent(
registry: McpRegistry,
) : Component {
private val provider = McpToolProvider(registry)
override fun install(agent: MutableAgent) {
if (provider !in agent.toolProviders)
agent.toolProviders += provider
}
override fun uninstall(agent: MutableAgent) {
agent.toolProviders -= provider
}
/**
* [ToolProvider] поверх [McpRegistry.allTools]: всегда отдаёт
* полный список MCP-тулов вне зависимости от `conversationId`
* (per-conversation фильтрация для MCP будет, если/когда понадобится —
* сейчас MCP-тулы глобальны и для всех бесед одинаковы).
*/
private class McpToolProvider(
private val registry: McpRegistry,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> = registry.allTools
}
}
val McpRegistry.component
get() = McpBridgeComponent(this)
@@ -32,7 +32,6 @@ import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Реестр подключённых MCP-серверов. * Реестр подключённых MCP-серверов.
@@ -60,11 +59,6 @@ class McpRegistry(
connected.values.flatMap { it.tools } connected.values.flatMap { it.tools }
} }
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
val namedTools: List<NamedTool> by lazy {
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
}
/** Количество успешно подключённых серверов. */ /** Количество успешно подключённых серверов. */
val connectedServerCount: Int get() = connected.size val connectedServerCount: Int get() = connected.size
@@ -181,13 +175,13 @@ internal class McpLiteToolAdapter(
private val client: Client, private val client: Client,
) : LiteTool { ) : LiteTool {
internal val fullName: String = "${serverName}__${tool.name}" override val name: String = "${serverName}__${tool.name}"
override fun describe(): String = override fun describe(): String =
buildJsonObject { buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM. // Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor). // litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName) put("name", name)
put("description", tool.description ?: "") put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema()) put("parameters", tool.inputSchema.toJsonSchema())
}.toString() }.toString()
@@ -14,9 +14,9 @@ import kotlin.time.Instant
* идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла * идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла
* используются `Agent.getConversations` / `getConversation`. * используются `Agent.getConversations` / `getConversation`.
* *
* **История**: раньше жил в `:proto` (как `pw.binom.agentik.proto.AgentEvent`). * **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.AgentEvent`;
* После миграции в `:outbox-api` — `:proto.AgentEvent` стал typealias'ом, * typealias удалён 2026-09-21 (стирал nested-типы в `is`/`when`) — потребители
* backward-compat для существующих импортов сохранён. * импортируют напрямую из `pw.binom.agentik.outbox.AgentEvent`.
*/ */
@Serializable @Serializable
sealed interface AgentEvent { sealed interface AgentEvent {
@@ -45,4 +45,13 @@ sealed interface AgentEvent {
@Serializable @Serializable
@SerialName("renamed") @SerialName("renamed")
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
/**
* Обновлён `updatedAt` диалога (после `send()` или другого события,
* бампнувшего активность). Клиентский кэш [ConversationStore] может
* применить этот event для пересортировки списка.
*/
@Serializable
@SerialName("touched")
data class Touched(override val date: Instant, val id: String, val updatedAt: Instant) : AgentEvent
} }
@@ -9,17 +9,17 @@ import kotlinx.serialization.Serializable
* *
* Useful for admin dashboards, debug tools, parent agents: one subscription * Useful for admin dashboards, debug tools, parent agents: one subscription
* instead of N+1. For regular UI use two separate SSE feeds * instead of N+1. For regular UI use two separate SSE feeds
* ([AgentEvent] via `/events` и `Event` via `/conversations/{id}/events`); * ([AgentEvent] via `/events` и [Event] via `/conversations/{id}/events`);
* [CommonEvent] — for those who need everything in one place. * [CommonEvent] — for those who need everything in one place.
* *
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`. * Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
* *
* **История**: раньше жил в `:proto` (как `pw.binom.agentik.proto.CommonEvent`). * **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
* После миграции в `:outbox-api` — `:proto.CommonEvent` стал typealias'ом, * при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
* backward-compat для существующих импортов сохранён. `CommonEvent.Conversation` * но он стирал nested-типы (`CommonEvent.Agent`, `CommonEvent.Conversation`),
* ссылается на [Event] (тоже в `:outbox-api` теперь) — раньше был * что ломало `is CommonEvent.Agent` на стороне клиента. Typealias'ы
* `pw.binom.agentik.proto.Event`, теперь это `pw.binom.agentik.outbox.Event` * `Event`/`AgentEvent`/`CommonEvent` из `:proto` удалены — потребители
* (он тоже typealias-нут в `:proto.Event`). * импортируют напрямую из `pw.binom.agentik.outbox.*`.
*/ */
@Serializable @Serializable
sealed interface CommonEvent { sealed interface CommonEvent {
@@ -15,9 +15,12 @@ import kotlin.time.Instant
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`. * `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения. * `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
* *
* **История**: раньше жил в `:proto` (как `pw.binom.agentik.proto.Event`). * **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
* После миграции в `:outbox-api` — `:proto.Event` стал typealias'ом, * при миграции в `:outbox-api` был оставлен typealias в `:proto` для
* backward-compat для существующих импортов сохранён. * backward-compat, но он стирал nested-типы (`Event.End`, `Event.ToolCall`,
* `Event.ToolResult`), что ломало `is Event.End` на стороне клиента.
* Typealias удалён 2026-09-21 — потребители импортируют напрямую из
* `pw.binom.agentik.outbox.Event`.
*/ */
@Serializable @Serializable
sealed interface Event { sealed interface Event {
@@ -75,12 +78,26 @@ sealed interface Event {
/** /**
* Результат вызова тула. Приходит целиком после завершения исполнения. * Результат вызова тула. Приходит целиком после завершения исполнения.
* [id] совпадает с [ToolCall.id], к которому относится результат, и *
* с id `Message.ToolResult` в истории. * [toolCallId] = id [ToolCall], к которому относится результат, и
* `MessageRecord.ToolResult.toolCallId` в истории. Один Call → один Result,
* пара `(date, toolCallId)` уникальна — отдельный `id` в live-событии
* не нужен (PK живёт в персистентном журнале).
*
* [toolName] денормализован из соответствующего [ToolCall.toolName] —
* UI рендерит имя тула без локальной `Map<toolCallId, name>` и без риска
* «Result пришёл до Call». `null` допустим для backfill'а старых
* записей, у которых поле отсутствует, или теоретического случая
* Result без предшествующего Call (orphan).
*/ */
@Serializable @Serializable
@SerialName("tool_result") @SerialName("tool_result")
data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event data class ToolResult(
override val date: Instant,
val toolCallId: String,
val toolName: String? = null,
val result: String?,
) : Event
/** /**
* Ошибка хода. После неё поток завершается; дальнейшие события могут * Ошибка хода. После неё поток завершается; дальнейшие события могут
@@ -89,4 +106,60 @@ sealed interface Event {
@Serializable @Serializable
@SerialName("error") @SerialName("error")
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
/**
* Конвейер вызова тула упал (handler кинул Throwable, args не парсятся,
* kernel прибил таск). Отличается от [ToolResult]: там мы сообщаем LLM
* результат (даже если LLM его не понравился), тут — сигнал о
* внутренней ошибке **самого исполнения тула**. Используется
* background-подписчиками (например [ReflectionScheduler]-like
* компонентами) для накопления паттернов отказов. Клиенту
* показывается для transparency, но в UI особо не нужен.
*/
@Serializable
@SerialName("tool_failed")
data class ToolFailed(
override val date: Instant,
val toolCallId: String,
val toolName: String?,
val message: String,
val durationMs: Long,
) : Event
/**
* Диалог переходит в закрытое состояние ([Conversation.close] /
* [ConversationLoop.close] / `agent.deleteConversation`). Эмитится
* **до** освобождения ресурсов, чтобы background-подписчики
* (skill mining, reflection) успели сделать final pass. После
* `Closed` диалог уже удалён из `agent.getConversations()` и
* `getMessages()` отдаст только то, что осталось в журнале.
*
* Парный `Opening` намеренно отсутствует — симметрия не нужна,
* так как открытие тривиально (id уже известен с момента
* `Agent.createConversation` → [Event.ConversationCreated]
* / [AgentEvent.Created] в outbox'е).
*/
@Serializable
@SerialName("conversation_closing")
data class ConversationClosing(
override val date: Instant,
val conversationId: String,
) : Event
/**
* Compactor сжал старые turn'ы — [turnsCompacted] из истории исчезли.
* Эмитится **до** deletion для background-подписчиков (skill mining),
* чтобы они успели сделать pass на исчезающем контенте.
*
* В отличие от [ToolFailed]/[ConversationClosing], это событие
* семантически "много контента ушло" — subscribers могут решать,
* стоит ли тратить tokens на mining ([turnsCompacted] > N).
*/
@Serializable
@SerialName("compaction_triggered")
data class CompactionTriggered(
override val date: Instant,
val conversationId: String,
val turnsCompacted: Int,
) : Event
} }
@@ -1,7 +1,6 @@
package pw.binom.agentik.proto package pw.binom.agentik.proto
import kotlinx.coroutines.flow.Flow import pw.binom.agentik.journal.ConversationStore
import kotlinx.coroutines.flow.flow
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import kotlin.time.Instant import kotlin.time.Instant
@@ -13,11 +12,16 @@ import kotlin.time.Instant
* [createConversation] возвращает [Conversation], который сам хранит историю * [createConversation] возвращает [Conversation], который сам хранит историю
* и которому отправляют ходы через [Conversation.send]. * и которому отправляют ходы через [Conversation.send].
* *
* **Хранилища вынесены в [Agent.journal] и [Agent.outbox]**: оба read-only. * **Хранилища вынесены в [Agent.journal], [Agent.outbox] и
* События больше НЕ часть [Agent] (раньше были `events()`/`allEvents()`) — * [Agent.conversationStore]**: все три read-only views. События живут
* они теперь живут в [outbox] как `OutboxStore.events(after)` / * в [outbox] как `OutboxStore.events(after)` / `outbox.agentEvents(after)`.
* `outbox.agentEvents(after)`. Это даёт единый путь для всех read-операций * Это даёт единый путь для всех read-операций по хранилищу и убирает
* по хранилищу и убирает дублирование между протоколом и хранилищем. * дублирование между протоколом и хранилищем.
*
* **Команды** (create / delete / rename) живут прямо на [Agent]. Они
* шлются клиентом и выполняются сервером — клиент **не** пишет в стор
* напрямую. Клиентский кэш [conversationStore] обновляется через
* `outbox.agentEvents()` (Created / Deleted / Renamed / Touched).
*/ */
interface Agent : AutoCloseable { interface Agent : AutoCloseable {
@@ -59,6 +63,22 @@ interface Agent : AutoCloseable {
*/ */
val outbox: OutboxStore val outbox: OutboxStore
/**
* Read-only view на `conversation` table (id + title + timestamps).
*
* Используется HTTP-фасадом `:server` для endpoint'а
* `GET /{path}/conversations?offset=&limit=` — внешние клиенты
* получают лёгкую метадату (без handle'ов и image-support флагов)
* для рендера списка диалогов. Для активной работы (send / interrupt)
* клиент отдельно получает handle через [getConversation].
*
* **Read-only**: write-доступ только через `MutableConversationStore`
* внутри ChatAgent, не через [Agent] interface. Клиент модифицирует
* диалоги командами: [createConversation] / [deleteConversation] /
* [renameConversation].
*/
val conversationStore: ConversationStore
/** Создаёт новый stateful-диалог с агентом. */ /** Создаёт новый stateful-диалог с агентом. */
fun createConversation(temp: Boolean): Conversation fun createConversation(temp: Boolean): Conversation
@@ -68,19 +88,11 @@ interface Agent : AutoCloseable {
/** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */ /** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */
suspend fun deleteConversation(id: String): Boolean suspend fun deleteConversation(id: String): Boolean
/** Страница диалогов: не более [limit] штук, начиная с [offset]-го. */ /**
suspend fun getConversations(offset: Int, limit: Int): List<Conversation> * Переименовывает диалог; `null` для сброса заголовка. Возвращает новый
* `updatedAt` или `null`, если диалог не найден.
/** Все диалоги, начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */ */
fun getConversations(offset: Int = 0): Flow<Conversation> = flow { suspend fun renameConversation(id: String, title: String?): Instant?
var skip = offset
while (true) {
val page = getConversations(skip, PAGE_SIZE)
if (page.isEmpty()) break
page.forEach { emit(it) }
skip += page.size
}
}
companion object { companion object {
@@ -1,10 +0,0 @@
package pw.binom.agentik.proto
/**
* Backward-compat typealias: `AgentEvent` теперь живёт в `:outbox-api`
* (логически принадлежит сущности outbox, не wire-протоколу `:proto`).
*
* Существующие импорты `pw.binom.agentik.proto.AgentEvent` продолжают
* работать транспарентно. Использовать typealias в новом коде.
*/
typealias AgentEvent = pw.binom.agentik.outbox.AgentEvent
@@ -1,9 +0,0 @@
package pw.binom.agentik.proto
/**
* Backward-compat typealias: `CommonEvent` теперь живёт в `:outbox-api`.
*
* Существующие импорты `pw.binom.agentik.proto.CommonEvent` продолжают
* работать транспарентно.
*/
typealias CommonEvent = pw.binom.agentik.outbox.CommonEvent
@@ -8,6 +8,19 @@ import kotlin.time.Instant
* Stateful-диалог клиента и [Agent]. Хранит собственную историю: на каждый * Stateful-диалог клиента и [Agent]. Хранит собственную историю: на каждый
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри * [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
* [Conversation]. * [Conversation].
*
* **Live-события** диалога (turn stream: StartReasoning / AppendText / End /
* ToolCall / ToolResult / ...) НЕ часть этого интерфейса — единственный
* источник live-событий это [pw.binom.agentik.outbox.OutboxStore].
* Подписаться на события конкретного диалога:
* ```
* agent.outbox.conversationEvents(after = lastSeen, conversationId = id)
* .map { it.event }
* .collect { e -> ... }
* ```
* Для cross-conversation view (admin / parent-agent / debug):
* `agent.outbox.events(after)`. Для lifecycle агента (created/deleted/renamed):
* `agent.outbox.agentEvents(after)`.
*/ */
interface Conversation : AutoCloseable { interface Conversation : AutoCloseable {
val id: String val id: String
@@ -33,7 +46,7 @@ interface Conversation : AutoCloseable {
/** /**
* Ставит новый user-ход в очередь. Возвращает управление сразу — поток * Ставит новый user-ход в очередь. Возвращает управление сразу — поток
* событий ответа приходит через [events]. * событий ответа приходит через `agent.outbox.conversationEvents(...)`.
* *
* Если в момент вызова выполняется другой ход, новый встаёт в очередь * Если в момент вызова выполняется другой ход, новый встаёт в очередь
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send]. * за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
@@ -48,25 +61,13 @@ interface Conversation : AutoCloseable {
/** /**
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается, * Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
* in-flight tool может доехать или отвалиться). В [events] эмитится * in-flight tool может доехать или отвалиться). В `agent.outbox.conversationEvents`
* [Event.Interrupted], затем может начаться следующий ход из очереди. * эмитится `Event.Interrupted`, затем может начаться следующий ход из очереди.
* *
* Если хода нет — no-op. * Если хода нет — no-op.
*/ */
suspend fun interrupt() suspend fun interrupt()
/**
* Live-подписка на всё, что происходит в диалоге, начиная с [after].
*
* **Не реплеит** события, произошедшие до [after] — для бэкфилла
* используй [getMessages]. Если [after] — момент последнего виденного
* клиентом события, поток продолжается «с того места».
*
* Подписки независимы: каждый вызов возвращает свой [Flow], отмена одного
* не влияет на других подписчиков и на сам диалог.
*/
fun events(after: Instant): Flow<Event>
/** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */ /** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
@@ -83,7 +84,7 @@ interface Conversation : AutoCloseable {
/** /**
* Освобождает ресурсы диалога (подписки, сетевые хэндлы). Идемпотентно. * Освобождает ресурсы диалога (подписки, сетевые хэндлы). Идемпотентно.
* После [close] дальнейшие вызовы [send]/[interrupt]/[events]/[getMessages]/[rename] не определены. * После [close] дальнейшие вызовы [send]/[interrupt]/[getMessages]/[rename] не определены.
*/ */
override fun close() override fun close()
@@ -1,11 +0,0 @@
package pw.binom.agentik.proto
/**
* Backward-compat typealias: `Event` теперь живёт в `:outbox-api`.
*
* Существующие импорты `pw.binom.agentik.proto.Event` продолжают
* работать транспарентно. `Conversation.events(after): Flow<Event>` в
* `:proto.Conversation` теперь фактически возвращает
* `pw.binom.agentik.outbox.Event` — тот же тип, другое имя.
*/
typealias Event = pw.binom.agentik.outbox.Event
@@ -45,9 +45,22 @@ sealed interface Message {
override val date: Instant override val date: Instant
) : Message ) : Message
/**
* Результат вызова тула. Приходит в историю `getMessages` после завершения хода.
* [id] совпадает с [ToolCall.id], к которому относится результат, и
* с id соответствующего `MessageRecord.ToolResult` в journal.
*
* [toolName] денормализован из [ToolCall.toolName] — UI рендерит
* имя тула в строке результата без отдельной `Map<id, name>`.
*/
@Serializable @Serializable
@SerialName("tool_result") @SerialName("tool_result")
class ToolResult(override val id: String, val result: String?, override val date: Instant) : Message class ToolResult(
override val id: String,
val toolName: String? = null,
val result: String?,
override val date: Instant,
) : Message
/** /**
* Ход завершился ошибкой (LLM, инициализация движка или иная отказоустойчивая * Ход завершился ошибкой (LLM, инициализация движка или иная отказоустойчивая
@@ -24,8 +24,9 @@ data class Reflection(
) )
/** /**
* Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу (impl в * Хранилище рефлексий. Реализации:
* `:storage-sqlite`) или in-memory (impl в `:storage-inmemory`). * - `:reflection-ksqlite` — таблица `reflection` поверх ksqlite
* - `:reflection-inmemory` — `Map<String, Reflection>` для тестов / dev
* *
* Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup) * Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup)
* или архивируются через [Curator]-подобный процесс, но не редактируются. * или архивируются через [Curator]-подобный процесс, но не редактируются.
@@ -33,8 +34,10 @@ data class Reflection(
interface ReflectionStore : AutoCloseable { interface ReflectionStore : AutoCloseable {
suspend fun insert(reflection: Reflection) suspend fun insert(reflection: Reflection)
suspend fun get(id: String): Reflection? suspend fun get(id: String): Reflection?
/** Самые свежие рефлексии (по всему агенту). */ /** Самые свежие рефлексии (по всему агенту). */
suspend fun listRecent(limit: Int = 10): List<Reflection> suspend fun listRecent(limit: Int = 10): List<Reflection>
/** Рефлексии для конкретного диалога. */ /** Рефлексии для конкретного диалога. */
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection> suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
suspend fun deleteOlderThan(cutoff: Instant) suspend fun deleteOlderThan(cutoff: Instant)
@@ -55,5 +58,5 @@ sealed interface ReflectionEvent {
* БД-схемах было видно сразу. * БД-схемах было видно сразу.
*/ */
object Ids { object Ids {
fun new(): String = "refl-${kotlin.uuid.Uuid.random()}" fun new() = "refl-${kotlin.uuid.Uuid.random()}"
} }
+36
View File
@@ -0,0 +1,36 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация [ReflectionStore] поверх in-memory структур
// (`MutableMap`/`MutableList` + `Mutex`) — для тестов, dev-режима,
// embedded-сценариев (Android core, CLI, in-process кэш в клиенте)
// и как образец для своей реализации.
//
// Зависимости: только `:reflection-api` (+ `:journal-api` транзитивно
// через :reflection-api — ReflectionEvent.Created/и др. ссылаются на
// MessageRecord enum'ы). Никакого I/O — pure in-memory.
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":reflection-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.inmemory package pw.binom.agentik.reflection.inmemory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -11,7 +11,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* Thread-safe List-импл [ReflectionStore]. * Thread-safe Map-импл [ReflectionStore].
* *
* `weakSpots` хранятся как List<String> в самой структуре — JSON-сериализация * `weakSpots` хранятся как List<String> в самой структуре — JSON-сериализация
* делается на уровне SQLDelight в SQLite-импле; здесь просто держим в памяти. * делается на уровне SQLDelight в SQLite-импле; здесь просто держим в памяти.
@@ -0,0 +1,95 @@
package pw.binom.agentik.reflection.inmemory
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.reflection.Reflection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.time.Instant
class InMemoryReflectionStoreTest {
private fun refl(
id: String,
conv: String? = null,
ts: Instant = Instant.parse("2026-09-15T10:00:00Z"),
score: Int = 3,
summary: String = "ok",
weak: List<String> = listOf("weak1", "weak2"),
) = Reflection(
id = id,
conversationId = conv,
createdAt = ts,
turnsAnalyzed = 5,
score = score,
summary = summary,
weakSpots = weak,
)
@Test
fun testInsertAndGetRoundtrip() = runTest {
val store = InMemoryReflectionStore()
store.insert(refl("r1"))
val got = store.get("r1")
assertNotNull(got)
assertEquals("r1", got.id)
assertEquals(listOf("weak1", "weak2"), got.weakSpots)
}
@Test
fun testGetReturnsNullForMissing() = runTest {
val store = InMemoryReflectionStore()
assertNull(store.get("nope"))
}
@Test
fun testListRecentSortedByCreatedAtDesc() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.insert(refl("r1", ts = t0))
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S")))
store.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S")))
val recent = store.listRecent(limit = 3)
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
}
@Test
fun testListForConversationFilters() = runTest {
val store = InMemoryReflectionStore()
val t = Instant.parse("2026-09-15T10:00:00Z")
store.insert(refl("r1", conv = "c-1"))
store.insert(refl("r2", conv = "c-2"))
store.insert(refl("r3", conv = "c-1"))
val forC1 = store.listForConversation("c-1", limit = 10)
assertEquals(2, forC1.size)
}
@Test
fun testDeleteOlderThanRemoves() = runTest {
val store = InMemoryReflectionStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.insert(refl("r1", ts = t0))
store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
store.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
assertNull(store.get("r1"))
assertNotNull(store.get("r2"))
}
@Test
fun testCountReturnsTotal() = runTest {
val store = InMemoryReflectionStore()
assertEquals(0, store.count())
store.insert(refl("r1"))
store.insert(refl("r2"))
assertEquals(2, store.count())
}
@Test
fun testCloseIdempotent() = runTest {
val store = InMemoryReflectionStore()
store.close()
store.close() // no-op
}
}
+38
View File
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
// KMP-реализация :reflection-api (ReflectionStore) поверх ksqlite.
//
// Минимальная — только таблица `reflection` + 2 индекса по ней.
// Остальные таблицы (`conversation`, `message`, `working_memory`) живут в
// других ksqlite-модулях; этот модуль не претендует на полную схему агента.
//
// Цели сборки — jvm() + linuxX64() + mingwX64(); Apple targets auto-disabled
// на Linux (ksqlite 0.1.2 не публикует native артефакты для Apple).
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")
api(project(":reflection-api"))
// :reflection-api ссылается на :journal-api типы в сигнатурах
// (MessageRecord и т.п. для enum'ов). Фиксируем явно чтобы
// downstream-консьюмеры не должны были декларировать ещё раз.
api(project(":journal-api"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.reflection.ksqlite
import pw.binom.agentik.reflection.Reflection import pw.binom.agentik.reflection.Reflection
import pw.binom.agentik.reflection.ReflectionEvent import pw.binom.agentik.reflection.ReflectionEvent
@@ -16,11 +16,42 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class KsqliteReflectionStore( /**
* ksqlite-реализация [ReflectionStore].
*
* ## Lifecycle соединения
*
* Семантика владения connection'ом идентична
* `pw.binom.agentik.journal.ksqlite.KsqliteJournalStore`:
* - `KsqliteReflectionStore(connection)` — внешнее соединение, store НЕ
* закрывает его в [close] и НЕ прогоняет миграцию (shared-connection bundle).
* - `KsqliteReflectionStore(path)` — открывает файловое соединение,
* прогоняет [Schema.migrate], закрывает соединение в [close].
* - `KsqliteReflectionStore.memory(name)` — in-memory, мигрирует,
* закрывает в [close].
*/
class KsqliteReflectionStore private constructor(
private val connection: SQLiteConnection, private val connection: SQLiteConnection,
private val ownsConnection: Boolean,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
) : ReflectionStore { ) : ReflectionStore {
constructor(path: String, clock: Clock = Clock.System) : this(
connection = SQLiteConnection.open(path = path),
ownsConnection = true,
clock = clock,
)
constructor(connection: SQLiteConnection, clock: Clock = Clock.System) : this(
connection = connection,
ownsConnection = false,
clock = clock,
)
init {
Schema.migrate(connection)
}
private val mutex = Mutex() private val mutex = Mutex()
private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16) private val ev = MutableSharedFlow<ReflectionEvent>(extraBufferCapacity = 16)
@@ -155,6 +186,16 @@ class KsqliteReflectionStore(
listForConvStmt.close() listForConvStmt.close()
deleteOlderThanStmt.close() deleteOlderThanStmt.close()
countStmt.close() countStmt.close()
if (ownsConnection) connection.close()
}
companion object {
fun memory(name: String? = null, clock: Clock = Clock.System): KsqliteReflectionStore =
KsqliteReflectionStore(
connection = SQLiteConnection.memory(name),
ownsConnection = true,
clock = clock,
)
} }
private fun SQLiteResultSet.toDomain(): Reflection = Reflection( private fun SQLiteResultSet.toDomain(): Reflection = Reflection(
@@ -168,7 +209,7 @@ class KsqliteReflectionStore(
) )
} }
/** Hand-rolled JSON-encode/decode для List<String> — синхронизировать с :storage-sqlite. */ /** Hand-rolled JSON-encode/decode для List<String>. */
internal fun encodeStringArray(items: List<String>): String = buildString { internal fun encodeStringArray(items: List<String>): String = buildString {
append('[') append('[')
items.forEachIndexed { i, s -> items.forEachIndexed { i, s ->
@@ -0,0 +1,71 @@
package pw.binom.agentik.reflection.ksqlite
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Имена таблиц/колонок/индексов для ksqlite-бэкенда `:reflection-api`.
*
* Минимум — только то, что относится к `reflection` (реализация
* [KsqliteReflectionStore]). Остальные таблицы агента (`conversation`,
* `message`, `working_memory`) живут в других ksqlite-модулях.
*
* Все DDL/DML в этом модуле должны ссылаться на эти константы — никаких
* хардкоженных литералов в `prepare("SELECT ... FROM foo ...")` в store'е.
*/
internal object Schema {
/** Версия схемы модуля. Увеличивать при ЛЮБОМ изменении DDL. */
const val CURRENT_VERSION: Int = 1
// ───── Таблица ─────
const val TABLE_REFLECTION = "reflection"
// ───── Колонки ─────
const val COL_ID = "id"
const val COL_CONVERSATION_ID = "conversation_id"
const val COL_CREATED_AT = "created_at"
const val COL_TURNS_ANALYZED = "turns_analyzed"
const val COL_SCORE = "score"
const val COL_SUMMARY = "summary"
const val COL_WEAK_SPOTS_JSON = "weak_spots_json"
// ───── Индексы ─────
const val IDX_REFLECTION_CREATED = "idx_reflection_created"
const val IDX_REFLECTION_CONV = "idx_reflection_conv"
private val v1Ddl = """
CREATE TABLE IF NOT EXISTS $TABLE_REFLECTION (
$COL_ID TEXT NOT NULL PRIMARY KEY,
$COL_CONVERSATION_ID TEXT,
$COL_CREATED_AT INTEGER NOT NULL,
$COL_TURNS_ANALYZED INTEGER NOT NULL,
$COL_SCORE INTEGER NOT NULL,
$COL_SUMMARY TEXT NOT NULL,
$COL_WEAK_SPOTS_JSON TEXT NOT NULL DEFAULT '[]'
);
""".trimIndent()
private val v1IndexesDdl = """
CREATE INDEX IF NOT EXISTS $IDX_REFLECTION_CREATED
ON $TABLE_REFLECTION($COL_CREATED_AT DESC);
CREATE INDEX IF NOT EXISTS $IDX_REFLECTION_CONV
ON $TABLE_REFLECTION($COL_CONVERSATION_ID, $COL_CREATED_AT DESC);
""".trimIndent()
/**
* Прогоняет миграцию схемы до [CURRENT_VERSION] на пустой или существующей БД.
*
* Идемпотентен: повторный вызов на уже мигрированной БД — no-op.
*/
fun migrate(conn: SQLiteConnection) {
conn.exec("BEGIN")
try {
conn.exec(v1Ddl)
conn.exec(v1IndexesDdl)
conn.exec("COMMIT")
} catch (t: Throwable) {
runCatching { conn.exec("ROLLBACK") }
throw t
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.storage.ksqlite package pw.binom.agentik.reflection.ksqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import pw.binom.agentik.reflection.Reflection import pw.binom.agentik.reflection.Reflection
@@ -8,19 +8,18 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
class KsqliteReflectionStoreTest { class KsqliteReflectionStoreTest {
private lateinit var stores: KsqliteStores private lateinit var store: KsqliteReflectionStore
@BeforeTest @BeforeTest
fun setup() { fun setup() {
stores = KsqliteStores.inMemory("refl-${kotlin.random.Random.nextLong()}") store = KsqliteReflectionStore.memory("refl-${kotlin.random.Random.nextLong()}")
} }
@AfterTest @AfterTest
fun tearDown() = stores.close() fun tearDown() = store.close()
private fun refl( private fun refl(
id: String, id: String,
@@ -41,8 +40,8 @@ class KsqliteReflectionStoreTest {
@Test @Test
fun testInsertAndGetRoundtrip() = runTest { fun testInsertAndGetRoundtrip() = runTest {
stores.reflections.insert(refl("r1")) store.insert(refl("r1"))
val got = stores.reflections.get("r1") val got = store.get("r1")
assertNotNull(got) assertNotNull(got)
assertEquals("r1", got.id) assertEquals("r1", got.id)
assertEquals(listOf("weak1", "weak2"), got.weakSpots) assertEquals(listOf("weak1", "weak2"), got.weakSpots)
@@ -50,45 +49,45 @@ class KsqliteReflectionStoreTest {
@Test @Test
fun testGetReturnsNullForMissing() = runTest { fun testGetReturnsNullForMissing() = runTest {
assertNull(stores.reflections.get("nope")) assertNull(store.get("nope"))
} }
@Test @Test
fun testListRecentSortedByCreatedAtDesc() = runTest { fun testListRecentSortedByCreatedAtDesc() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", ts = t0)) store.insert(refl("r1", ts = t0))
stores.reflections.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S"))) store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT60S")))
stores.reflections.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S"))) store.insert(refl("r3", ts = t0 + kotlin.time.Duration.parse("PT120S")))
val recent = stores.reflections.listRecent(limit = 3) val recent = store.listRecent(limit = 3)
assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id }) assertEquals(listOf("r3", "r2", "r1"), recent.map { it.id })
} }
@Test @Test
fun testListForConversationFilters() = runTest { fun testListForConversationFilters() = runTest {
val t = Instant.parse("2026-09-15T10:00:00Z") val t = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", conv = "c-1")) store.insert(refl("r1", conv = "c-1"))
stores.reflections.insert(refl("r2", conv = "c-2")) store.insert(refl("r2", conv = "c-2"))
stores.reflections.insert(refl("r3", conv = "c-1")) store.insert(refl("r3", conv = "c-1"))
val forC1 = stores.reflections.listForConversation("c-1", limit = 10) val forC1 = store.listForConversation("c-1", limit = 10)
assertEquals(2, forC1.size) assertEquals(2, forC1.size)
} }
@Test @Test
fun testDeleteOlderThanRemoves() = runTest { fun testDeleteOlderThanRemoves() = runTest {
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
stores.reflections.insert(refl("r1", ts = t0)) store.insert(refl("r1", ts = t0))
stores.reflections.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H"))) store.insert(refl("r2", ts = t0 + kotlin.time.Duration.parse("PT1H")))
stores.reflections.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M")) store.deleteOlderThan(t0 + kotlin.time.Duration.parse("PT30M"))
assertNull(stores.reflections.get("r1")) assertNull(store.get("r1"))
assertNotNull(stores.reflections.get("r2")) assertNotNull(store.get("r2"))
} }
@Test @Test
fun testCountReturnsTotal() = runTest { fun testCountReturnsTotal() = runTest {
assertEquals(0, stores.reflections.count()) assertEquals(0, store.count())
stores.reflections.insert(refl("r1")) store.insert(refl("r1"))
stores.reflections.insert(refl("r2")) store.insert(refl("r2"))
assertEquals(2, stores.reflections.count()) assertEquals(2, store.count())
} }
} }
@@ -0,0 +1,64 @@
package pw.binom.agentik.reflection.ksqlite
import kotlinx.coroutines.test.runTest
import pw.binom.db.ksqlite.SQLiteConnection
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Тесты на Schema.migrate() в `:reflection-ksqlite`:
* - fresh DB → создаются `reflection` + индексы;
* - уже мигрированная БД → migrate() идемпотентен (no-op).
*/
class SchemaMigrationTest {
@Test
fun `fresh DB gets reflection table and indexes`() = runTest {
val conn = SQLiteConnection.memory("refl-mig-fresh-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_REFLECTION), "table '${Schema.TABLE_REFLECTION}' should exist after migrate()")
for (index in listOf(
Schema.IDX_REFLECTION_CREATED,
Schema.IDX_REFLECTION_CONV,
)) {
assertTrue(indexExists(conn, index), "index '$index' should exist after migrate()")
}
} finally {
conn.close()
}
}
@Test
fun `migrate is idempotent on already-migrated DB`() = runTest {
val conn = SQLiteConnection.memory("refl-mig-idem-${kotlin.random.Random.nextLong()}")
try {
Schema.migrate(conn)
// повторный вызов не должен ни упасть, ни пересоздать таблицы
Schema.migrate(conn)
Schema.migrate(conn)
assertTrue(tableExists(conn, Schema.TABLE_REFLECTION))
} finally {
conn.close()
}
}
private fun tableExists(conn: SQLiteConnection, name: String): Boolean {
conn.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?"
).use { stmt ->
stmt.bindText(1, name)
stmt.executeQuery().use { rs -> return rs.next() }
}
}
private fun indexExists(conn: SQLiteConnection, name: String): Boolean {
conn.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?"
).use { stmt ->
stmt.bindText(1, name)
stmt.executeQuery().use { rs -> return rs.next() }
}
}
}
@@ -3,8 +3,8 @@ package pw.binom.agentik.server
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.route import io.ktor.server.routing.route
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.CommonEvent
/** /**
* HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента). * HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента).
@@ -20,11 +20,11 @@ import kotlinx.coroutines.flow.map
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.CommonEvent
import pw.binom.agentik.proto.Conversation import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.Event
import kotlin.time.Instant import kotlin.time.Instant
internal fun Route.agentikRoutes(agent: Agent) { internal fun Route.agentikRoutes(agent: Agent) {
@@ -38,7 +38,7 @@ internal fun Route.agentikRoutes(agent: Agent) {
get("/conversations") { get("/conversations") {
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Agent.PAGE_SIZE val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Agent.PAGE_SIZE
call.respond(agent.getConversations(offset, limit).map { it.snapshot() }) call.respond(agent.conversationStore.list(offset, limit))
} }
post("/conversations") { post("/conversations") {
@@ -63,14 +63,15 @@ internal fun Route.agentikRoutes(agent: Agent) {
patch("/conversations/{id}") { patch("/conversations/{id}") {
val id = call.parameters["id"]!! val id = call.parameters["id"]!!
val c = agent.getConversation(id) val req = call.receive<RequestRename>()
if (c == null) { val newUpdatedAt = agent.renameConversation(id, req.title)
if (newUpdatedAt == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return@patch return@patch
} }
val req = call.receive<RequestRename>() // Возвращаем обновлённый record (лёгкая метадата, не snapshot handle'а).
c.rename(req.title) val rec = agent.conversationStore.get(id)!!
call.respond(c.snapshot()) call.respond(rec)
} }
post("/conversations/{id}/messages") { post("/conversations/{id}/messages") {
@@ -128,7 +129,10 @@ internal fun Route.agentikRoutes(agent: Agent) {
return@get return@get
} }
val after = call.parseAfter() ?: return@get val after = call.parseAfter() ?: return@get
call.streamJsonSse(c.events(after), Event.serializer()) // Live-источник событий — `OutboxStore` (единая точка истины);
// разворачиваем `CommonEvent.Conversation` → `Event` для совместимости
// wire-формата (клиент десериализует как `Event`, не как `CommonEvent.Conversation`).
call.streamJsonSse(agent.outbox.conversationEvents(after, id).map { it.event }, Event.serializer())
} }
get("/events") { get("/events") {
@@ -13,6 +13,7 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.CommonEvent import pw.binom.agentik.outbox.CommonEvent
@@ -53,10 +54,15 @@ class BearerTokenTest {
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {} override fun close() {}
} }
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String) = null
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {}
}
override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests") override fun createConversation(temp: Boolean): Conversation = TODO("not needed by tests")
override suspend fun getConversation(id: String): Conversation? = null override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false override suspend fun deleteConversation(id: String): Boolean = false
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> = emptyList() override suspend fun renameConversation(id: String, title: String?): Instant? = null
override fun close() {} override fun close() {}
} }
+37 -24
View File
@@ -34,7 +34,7 @@ include(":standalone")
include(":llm-tools") include(":llm-tools")
// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены // Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены
// из :standalone — MCP не специфичен для standalone'а, это generic мост // из :standalone — MCP не специфичен для standalone'а, это generic мост
// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool). // между MCP-SDK и LiteTool.
include(":mcp-bridge") include(":mcp-bridge")
// Собственный протокол agentik. Пока в нём пилим, потом вынесем. // Собственный протокол agentik. Пока в нём пилим, потом вынесем.
include(":proto") include(":proto")
@@ -61,18 +61,15 @@ include(":memory-md")
// для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART // для метаданных. JVM-only (JVector не имеет KMP-таргетов). На Android ART
// работает через Java 11 base classes (scalar fallback). // работает через Java 11 base classes (scalar fallback).
include(":memory-vector") include(":memory-vector")
// Интерфейсы хранилища и разговорной истории: MessageStore / WorkingMemoryStore / // Интерфейсы хранилища и разговорной истории. Контракты распределены по
// ConversationStore / ReflectionStore + StorageBundle агрегатор. Реализации — // доменам: :journal-api (MutableJournalStore/MutableConversationStore),
// в :storage-inmemory / :storage-sqlite / :storage-android (последний deferred). // :context-api (ContextStore), :reflection-api (ReflectionStore).
// In-memory реализация store'ов из :journal-api и :context-api. KMP, // Реализации — :journal-ksqlite/:context-ksqlite/:reflection-ksqlite (ksqlite)
// IO-зависимостей. Используется в тестах (быстрый setup, без JDBC) и будет // и :journal-inmemory/:reflection-inmemory (in-memory).
// использоваться в Android-сборке (JVector/SQLite не подходят для ART out-of-box).
//include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids) //include(":message-store-api") — перенесено в :journal-api (ConversationRecord/ConversationStore/Ids)
// и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids) // и :reflection-api (Reflection/ReflectionStore/ReflectionEvent/Ids)
// Модуль удалён с диска. // Модуль удалён с диска.
include(":reflection-api") include(":reflection-api")
//include(":message-log-api")
//include(":message-log-ksqlite")
// Новые API-модули трёх сущностей (canonical имена): // Новые API-модули трёх сущностей (canonical имена):
// - :journal-api — append-only audit log (бывший :message-log-api) // - :journal-api — append-only audit log (бывший :message-log-api)
// - :context-api — то что видит LLM (бывший :working-memory-api) // - :context-api — то что видит LLM (бывший :working-memory-api)
@@ -97,7 +94,6 @@ include(":journal-inmemory")
include(":memory-md-vector") include(":memory-md-vector")
//include(":event-store-in-memory") //include(":event-store-in-memory")
//include(":working-memory-api") //include(":working-memory-api")
include(":storage-inmemory")
// SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only // SQLDelight-реализация store'ов из :journal-api и :context-api. JVM-only
// KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite). // KMP-реализация EventStore поверх ksqlite (https://github.com/caffeine-mgn/ksqlite).
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector // Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector
@@ -105,25 +101,42 @@ include(":storage-inmemory")
// Linux — собираются локально на macOS. Пока покрывает только EventStore; // Linux — собираются локально на macOS. Пока покрывает только EventStore;
// остальные store'ы мигрируют после стабилизации ksqlite и реального // остальные store'ы мигрируют после стабилизации ksqlite и реального
// использования на Android-агенте. // использования на Android-агенте.
include(":storage-ksqlite")
// ksqlite-реализация :context-api (ContextStore / working_memory table). // ksqlite-реализация :context-api (ContextStore / working_memory table).
// Минимальный модуль: только таблица `working_memory` + 2 индекса. // Минимальный модуль: только таблица `working_memory` + 2 индекса.
// Параллельно существует :storage-ksqlite/KsqliteWorkingMemoryStore.kt —
// миграция consumers'ов по чуть-чуть, отдельно.
include(":context-ksqlite") include(":context-ksqlite")
// ksqlite-реализация :journal-api (JournalStore / message table). // ksqlite-реализация :journal-api (JournalStore / message table,
// Минимальный модуль: только таблица `message` + 1 индекс // ConversationStore / conversation table). Минимальный модуль:
// `(conversation_id, created_at)`. Параллельно существует // таблицы `message` + `conversation` + индексы.
// :storage-ksqlite/KsqliteMessageStore.kt — миграция consumers'ов
// по чуть-чуть, отдельно.
include(":journal-ksqlite") include(":journal-ksqlite")
// KMP-реализация EventStore через ksqlite (https://github.com/caffeine-mgn/ksqlite). // ksqlite-реализация :reflection-api (ReflectionStore / reflection table).
// Цель: проверить что pure-Kotlin SQLite с sqlite-vec заменяет SQLDelight+JVector // Минимальный модуль: только таблица `reflection` + 2 индекса.
// на KMP-таргетах (JVM + linuxX64 + mingwX64). Apple targets auto-disabled на include(":reflection-ksqlite")
// Linux — собираются локально на macOS. На этом этапе покрывает только EventStore; // in-memory реализация :reflection-api (ReflectionStore). Используется в
// остальные store'ы мигрируют после стабилизации ksqlite (>=0.2) и реального // тестах, dev-режиме и embedded-сценариях. Чистый KMP (9 таргетов),
// использования на Android-агенте. // никакого I/O.
include(":reflection-inmemory")
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные // Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть // тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+. // переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
include(":agent-toolsets") include(":agent-toolsets")
// API-модуль vector-index: интерфейсы ANN-индекса, общие для любых бэкендов
// (JVector, sqlite-vec, inmemory, ...). Pure KMP, без реализаций.
include(":vector-index-api")
// JVector-реализация `MutableVectorIndexStore`. JVM-only — JVector публикуется
// только под JVM (нет KMP-таргетов). In-RAM (без persistence в v1).
include(":vector-index-jvector")
// ksqlite-реализация `MutableVectorIndexStore`. KMP (все 9 целей) — brute-force
// ANN: SELECT всех записей + cosine similarity в Kotlin. Persistence — обычная
// SQLite БД. Подходит для small-to-medium масштабов; для больших — sqlite-vec
// или JVector (выше).
include(":vector-index-ksqlite")
// Расширяемый слой поверх :proto: `MutableAgent` (наследник Agent + install/uninstall
// компонентов), `Component` (install(MutableAgent)/uninstall(MutableAgent)) и три
// capability-провайдера (system-prompt, tools, event-listener). Каждый компонент
// сам регистрирует свои capability в MutableAgent при install и снимает их при
// uninstall. KMP, все 9 целей; чистый API-слой, без реализаций.
include(":agent-api")
// Skill-подсистема как компонент: тулы skill_save/skill_delete/skill_read +
// SkillMiningComponent, слушающий outbox (Event.ConversationClosing /
// Event.CompactionTriggered) и добывающий новые скилы через SkillMiner.
include(":skill-mining")
+44
View File
@@ -0,0 +1,44 @@
// Skill-подсистема как Component: тулы skill_save/skill_delete/skill_read +
// SkillMiningComponent, слушающий SkillMiningEvent и добывающий новые скилы
// через SkillMiner.
//
// KMP (9 целей — как :agent-toolsets): SkillMiningComponent в commonMain,
// SkillMiner из :llm-tools тоже KMP — нет блокеров для натива.
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :agent-api — Component / MutableAgent / ConversationAware.
api(project(":agent-api"))
// :skills — SkillStore, SkillCatalog, renderSystemPromptSection.
api(project(":skills"))
// :memory-api — typealias ConversationTurn.
api(project(":memory-api"))
// :litert-api — LiteTool / LiteLlm.
api(libs.litert.api)
// Корутины для фонового слушателя событий.
api(libs.kotlinx.coroutines.core)
// Логгер для SkillMiner.
implementation(libs.kotlin.logging)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -35,7 +35,7 @@ class SkillMiner(
private val llm: LiteLlm, private val llm: LiteLlm,
val maxTurns: Int = 30, val maxTurns: Int = 30,
private val maxTokens: Int = 1536, private val maxTokens: Int = 1536,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) { ) {
/** /**
* Mine по последним [turns] с учётом текущего каталога [existing]. * Mine по последним [turns] с учётом текущего каталога [existing].
@@ -0,0 +1,155 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.ConversationAware
import pw.binom.agentik.agent.ConversationHandle
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.skills.renderSystemPromptSection
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteTool
/**
* События, по которым SkillMiningComponent решает, что пора майнить новые скилы.
*
* Standalone-часть мэпит свой [pw.binom.agentik.outbox.OutboxStore] (через
* [pw.binom.agentik.outbox.Event.ConversationClosing] и
* [pw.binom.agentik.outbox.Event.CompactionTriggered]) на этот sealed
* interface и подаёт результат в [SkillMiningComponent.events]. Делаем так,
* чтобы модуль :skill-mining не зависел от :standalone и его внутренних типов.
*/
sealed interface SkillMiningEvent {
val conversationId: String
/** Разговор закрывается — последний шанс вытащить скилы из контекста. */
data class ConversationClosing(override val conversationId: String) : SkillMiningEvent
/** Сработал compactor — старые turn'ы ушли, mining нужен на новом контексте. */
data class ConversationCompacted(override val conversationId: String) : SkillMiningEvent
}
/**
* Полная подсистема скилов как один Component:
* 1. Тулы skill_save / skill_delete / skill_read — через [SkillToolProvider].
* 2. Секция системного промпта с актуальным каталогом — через [SkillSystemProvider].
* 3. Фоновый майнер — на [SkillMiningEvent.ConversationClosing] /
* [SkillMiningEvent.ConversationCompacted] вызывает [SkillMiner] над
* последними turn'ами разговора и пишет новые скилы в [SkillStore].
*
* Поведение майнера зависит от per-conversation state ([ConversationHandle]):
* - isTemporal=true → пропускаем (mining не имеет смысла для temp-бесед);
* - recentTurns(limit) → что майнер видит.
*
* Подключается как обычный Component: `agent.install(SkillMiningComponent(...))`.
* Дополнительно компонент реализует [ConversationAware] — [MutableAgent]
* оповещает его о создании/закрытии каждого разговора.
*/
class SkillMiningComponent(
private val store: SkillStore,
private val llm: LiteLlm,
private val events: Flow<SkillMiningEvent>,
private val scope: CoroutineScope,
) : Component, ConversationAware {
private var toolProvider: SkillToolProvider? = null
private var systemProvider: SkillSystemProvider? = null
private var minerJob: Job? = null
/** Per-conversation context для mining: id → handle. */
private val handles = mutableMapOf<String, ConversationHandle>()
private val handlesLock = Mutex()
override fun install(agent: MutableAgent) {
val tp = SkillToolProvider(store)
agent.toolProviders.add(tp)
toolProvider = tp
val sp = SkillSystemProvider(store)
agent.systemProviders.add(sp)
systemProvider = sp
// Подписка на фоновые события — единая на компонент (не на разговор).
minerJob = scope.launch {
events.collect { event ->
when (event) {
is SkillMiningEvent.ConversationClosing,
is SkillMiningEvent.ConversationCompacted -> {
mineForConversation(event.conversationId)
}
}
}
}
}
override fun uninstall(agent: MutableAgent) {
minerJob?.cancel()
minerJob = null
toolProvider?.let { agent.toolProviders.remove(it) }
systemProvider?.let { agent.systemProviders.remove(it) }
// очистка под mutex: detach-вызовы могут быть в полёте из других корутин
runBlocking { handlesLock.withLock { handles.clear() } }
}
/** [ConversationAware]: новый разговор — запоминаем handle. */
override fun attachConversation(handle: ConversationHandle) {
// attach вызывается синхронно из MutableAgent.install-flow,
// целостность важна только между attach/detach и чтением из mining-корутины
runBlocking { handlesLock.withLock { handles[handle.id] = handle } }
}
/** [ConversationAware]: разговор закрыт — забываем handle. */
override fun detachConversation(handle: ConversationHandle) {
runBlocking { handlesLock.withLock { handles.remove(handle.id) } }
}
private fun mineForConversation(conversationId: String) {
// горячий путь: одна mining-корутина читает snapshot handle и идёт работать.
// блокировка короткая, copy-on-read не нужно — handle'ы immutable в нашей модели.
val handle = runBlocking { handlesLock.withLock { handles[conversationId] } } ?: return
if (handle.isTemporal) return
scope.launch {
val miner = SkillMiner(llm)
val turns = runCatching { handle.recentTurns(miner.maxTurns) }.getOrDefault(emptyList())
if (turns.isEmpty()) return@launch
runCatching {
// ConversationTurn (agent-api) == ConversationTurn (memory-api) через typealias.
miner.mine(turns, store.catalog.skills)
}
.onSuccess { mined ->
mined.forEach { runCatching { store.upsert(it) } }
}
}
}
}
private class SkillToolProvider(
private val store: SkillStore,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> {
val catalog: SkillCatalog = store.catalog
return buildList {
add(SkillSaveTool(store))
add(SkillDeleteTool(store))
if (!catalog.isEmpty) add(SkillReadTool(catalog))
}
}
}
private class SkillSystemProvider(
private val store: SkillStore,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String =
store.catalog.renderSystemPromptSection()
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
@@ -1,4 +1,4 @@
package pw.binom.agentik.llm.tools package pw.binom.agentik.skill.mining
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
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.skill.mining
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -22,6 +22,8 @@ class SkillReadTool(
private val catalog: SkillCatalog, private val catalog: SkillCatalog,
) : LiteTool { ) : LiteTool {
override val name: String get() = NAME
override fun describe(): String = buildJsonObject { override fun describe(): String = buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который // Flat OpenAPI-спецификация (name/description/parameters) — формат, который
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в // принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
@@ -23,30 +23,26 @@ import pw.binom.litert.LiteTool
* } * }
* } * }
* ``` * ```
*
* ВАЖНО: параметр LiteTool-лямбды не называется `invoke` — иначе Kotlin
* резолвит `invoke(x)` рекурсивно и StackOverflow (см. MemoryToolsFactory).
*/ */
internal class SkillSaveTool( class SkillSaveTool(
private val store: SkillStore, private val store: SkillStore,
) : LiteTool { ) : LiteTool {
override val name: String = "skill_save"
override fun describe(): String = SCHEMA override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String { override fun invoke(arguments: String): String {
val parsed = parseArgs(arguments) val parsed = parseArgs(arguments)
?: return error("invalid arguments: $arguments") ?: throw IllegalStateException("invalid arguments: $arguments")
val (name, description, body) = parsed val (name, description, body) = parsed
return try { return try {
store.upsert(SkillFile(name = name, description = description, body = body)) store.upsert(SkillFile(name = name, description = description, body = body))
"""{"ok":true,"name":"${escape(name)}"}""" """{"ok":true,"name":"${escape(name)}"}"""
} catch (e: Exception) { } catch (e: Exception) {
error(e.message ?: "upsert failed") throw IllegalStateException(e.message ?: "upsert failed")
} }
} }
private fun parseArgs(arguments: String): Triple<String, String, String>? { private fun parseArgs(arguments: String): Triple<String, String, String>? {
// Минимальный парсер JSON-объекта — вытаскиваем три строковых поля.
// Полагаемся на порядок ключей в агенте: name, description, body.
val name = extractString(arguments, "name") ?: return null val name = extractString(arguments, "name") ?: return null
val description = extractString(arguments, "description") ?: return null val description = extractString(arguments, "description") ?: return null
val body = extractString(arguments, "body") ?: return null val body = extractString(arguments, "body") ?: return null
@@ -75,22 +71,23 @@ internal class SkillSaveTool(
/** /**
* Тул `skill_delete(name)` — архивирует скил. * Тул `skill_delete(name)` — архивирует скил.
*/ */
internal class SkillDeleteTool( class SkillDeleteTool(
private val store: SkillStore, private val store: SkillStore,
) : LiteTool { ) : LiteTool {
override val name: String = "skill_delete"
override fun describe(): String = SCHEMA override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String { override fun invoke(arguments: String): String {
val name = extractString(arguments, "name") val name = extractString(arguments, "name")
?: return error("missing 'name' in arguments: $arguments") ?: throw IllegalStateException("missing 'name' in arguments: $arguments")
return try { return try {
val removed = store.remove(name) val removed = store.remove(name)
if (removed) { if (removed) {
"""{"ok":true,"archived":"${escape(name)}"}""" """{"ok":true,"archived":"${escape(name)}"}"""
} else { } else {
error("skill '$name' not found") throw IllegalStateException("skill '$name' not found")
} }
} catch (e: Exception) { } catch (e: Exception) {
error(e.message ?: "delete failed") throw IllegalStateException(e.message ?: "delete failed")
} }
} }
@@ -113,8 +110,7 @@ internal class SkillDeleteTool(
/** /**
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер — * Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
* JSON простой (плоский объект с известным набором ключей), как в * JSON простой (плоский объект с известным набором ключей).
* MemoryToolsFactory.
*/ */
internal fun extractString(json: String, key: String): String? { internal fun extractString(json: String, key: String): String? {
val keyIdx = json.indexOf("\"$key\"") val keyIdx = json.indexOf("\"$key\"")
@@ -163,6 +159,3 @@ internal fun escape(s: String): String = buildString(s.length + 2) {
} }
} }
} }
/** Строит `{"error":"..."}` JSON-ответ. */
internal fun error(message: String): Nothing = throw IllegalStateException(message)
@@ -0,0 +1,127 @@
package pw.binom.agentik.skill.mining
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
}
override fun sendContents(contents: List<LiteContentPart>): String {
parent.lastContents = contents
return parent.reply
}
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
LiteDelta(text = "", isDone = true)
override fun close() {}
}
@@ -1,11 +1,11 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiner import pw.binom.agentik.skill.mining.SkillMiner
class SkillMinerTest { class SkillMinerTest {
@@ -1,9 +1,9 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiningParser import pw.binom.agentik.skill.mining.SkillMiningParser
class SkillMiningParserTest { class SkillMiningParserTest {
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.skill.mining
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
@@ -19,7 +19,6 @@ class SkillReadToolTest {
@Test @Test
fun describeIsFlatOpenApiSchema() { fun describeIsFlatOpenApiSchema() {
val json = tool.describe() val json = tool.describe()
// Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
assertTrue("\"name\"" in json) assertTrue("\"name\"" in json)
assertTrue(SkillReadTool.NAME in json) assertTrue(SkillReadTool.NAME in json)
assertTrue("parameters" in json) assertTrue("parameters" in json)

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