Compare commits
9 Commits
8
..
639c7d1748
| Author | SHA1 | Date | |
|---|---|---|---|
| 639c7d1748 | |||
| 5f0e0da361 | |||
| acb4ee6186 | |||
| c0a933d251 | |||
| 29851c047a | |||
| c9995b263e | |||
| 7865eed836 | |||
| 0a7c40688c | |||
| 161be41adf |
@@ -5,6 +5,9 @@
|
|||||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||||
# release.yml для требуемых переменных.
|
# release.yml для требуемых переменных.
|
||||||
|
#
|
||||||
|
# :agentik-cli / :agentik-tui исключены из сборки (settings.gradle.kts
|
||||||
|
# 2026-09-17/21) — соответствующие шаги shadowJar тут НЕ запускаются.
|
||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -67,15 +70,10 @@ jobs:
|
|||||||
test -f standalone/build/libs/standalone-*-all.jar \
|
test -f standalone/build/libs/standalone-*-all.jar \
|
||||||
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||||
|
|
||||||
- name: Build :agentik-cli shadowJar
|
# Шага "Build :agentik-cli shadowJar" здесь нет: :agentik-cli исключён из
|
||||||
shell: bash
|
# settings.gradle.kts (2026-09-21). :agentik-tui — тоже исключён (2026-09-17).
|
||||||
run: |
|
# Когда/если оба вернутся, добавим отдельные шаги по аналогии с :standalone.
|
||||||
./gradlew :agentik-cli:shadowJar \
|
#
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
|
||||||
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
|
|
||||||
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
|
||||||
|
|
||||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ out/
|
|||||||
|
|
||||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
.cortexkit/
|
.cortexkit/
|
||||||
|
# opencode CLI local config (per-machine, не коммитим)
|
||||||
|
config.json
|
||||||
.veai/
|
.veai/
|
||||||
|
|
||||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
+192
-4
@@ -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` в
|
||||||
|
`:storage-ksqlite` как образец.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
|
||||||
|
class MySqliteConversationStore(db: MyDb) : MutableConversationStore {
|
||||||
|
override suspend fun upsert(record: ConversationRecord) { /* INSERT OR REPLACE */ }
|
||||||
|
override suspend fun get(id: String): ConversationRecord? { /* SELECT */ }
|
||||||
|
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> { /* SELECT ORDER BY updatedAt DESC */ }
|
||||||
|
override suspend fun delete(id: String): Boolean { /* DELETE */ }
|
||||||
|
override suspend fun rename(id: String, title: String?): Instant? { /* UPDATE + bump updatedAt */ }
|
||||||
|
override suspend fun touch(id: String, now: Instant) { /* UPDATE updatedAt */ }
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -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)`.
|
||||||
|
|
||||||
## Известное ограничение
|
## Известное ограничение
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT")
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
api(project(":context-api"))
|
api(project(":context-api"))
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ litert = "8"
|
|||||||
sqldelight = "2.3.2"
|
sqldelight = "2.3.2"
|
||||||
shadow = "8.3.5"
|
shadow = "8.3.5"
|
||||||
jvector = "3.0.6"
|
jvector = "3.0.6"
|
||||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
text-embedding-kmp = "5"
|
||||||
kotlin-logging = "3.0.5"
|
kotlin-logging = "3.0.5"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
mosaic = "0.18.0"
|
mosaic = "0.18.0"
|
||||||
@@ -99,6 +99,8 @@ jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
|||||||
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||||
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
|
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
|
||||||
# (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
|
# (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
|
||||||
|
# Версия 5 — первый релиз с реальными нативными klib-вариантами в caffeine
|
||||||
|
# (v4 имел только jvm+android, что ломало native-resolve в :memory-md-vector).
|
||||||
# Используется из :memory-md-vector и :memory-vector напрямую через
|
# Используется из :memory-md-vector и :memory-vector напрямую через
|
||||||
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
|
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
|
||||||
# нужный variant под target).
|
# нужный variant под target).
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot диалога. В таблице `conversation` хранится как есть.
|
||||||
|
*/
|
||||||
|
data class ConversationRecord(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val isTemporal: Boolean,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of the `conversation` table (CRUD-операции находятся
|
||||||
|
* в [MutableConversationStore] и используются только внутри ChatAgent).
|
||||||
|
*
|
||||||
|
* Клиенты видят [ConversationStore] через [pw.binom.agentik.proto.Agent.conversationStore]
|
||||||
|
* (по аналогии с `journal` / `outbox`) и строят свой локальный кэш:
|
||||||
|
* - **seed** через [list] (snapshot страницы) или [listFlow] (cold-flow paging);
|
||||||
|
* - **live-refresh** через `outbox.agentEvents()` — Created / Deleted /
|
||||||
|
* Renamed / Touched.
|
||||||
|
*
|
||||||
|
* Запись в хранилище **не** делается клиентом — только команды
|
||||||
|
* `agent.createConversation / deleteConversation / renameConversation`.
|
||||||
|
*/
|
||||||
|
interface ConversationStore : AutoCloseable {
|
||||||
|
|
||||||
|
/** Диалог по id, или `null`. */
|
||||||
|
suspend fun get(id: String): ConversationRecord?
|
||||||
|
|
||||||
|
/** Список диалогов, отсортированный по `updatedAt` DESC. */
|
||||||
|
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cold-flow paging через [list]. Default-реализация делает N+1 round-trip
|
||||||
|
* (по странице через `list()` пока не получит короткую страницу). Для
|
||||||
|
* HTTP-импл — это лишние round-trip'ы; реализация может переопределить.
|
||||||
|
*/
|
||||||
|
fun listFlow(offset: Int = 0, pageSize: Int = PAGE_SIZE): Flow<ConversationRecord> = flow {
|
||||||
|
var skip = offset
|
||||||
|
while (true) {
|
||||||
|
val page = list(skip, pageSize)
|
||||||
|
if (page.isEmpty()) break
|
||||||
|
page.forEach { emit(it) }
|
||||||
|
skip += page.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PAGE_SIZE: Int = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pw.binom.agentik.journal
|
||||||
|
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генератор id. Использует `kotlin.uuid.Uuid` из stdlib (KMP: jvm + native),
|
||||||
|
* чтобы не зависеть от `java.util.UUID` и подготовить код к linuxX64-сборке.
|
||||||
|
*
|
||||||
|
* Сохраняет формат `<prefix>-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` —
|
||||||
|
* `:server` его парсит как opaque string, без знания внутренней структуры.
|
||||||
|
*/
|
||||||
|
object Ids {
|
||||||
|
fun new(prefix: String): String = "$prefix-${Uuid.random()}"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+21
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+17
-6
@@ -1,22 +1,33 @@
|
|||||||
package pw.binom.agentik.storage.inmemory
|
package pw.binom.agentik.journal.inmemory
|
||||||
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import 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: MutableMap<String, ConversationRecord> = mutableMapOf()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
+11
-11
@@ -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
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT")
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
|
|||||||
+13
-3
@@ -30,7 +30,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 +59,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 +72,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?)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score
|
||||||
|
* в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||||
|
*
|
||||||
|
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||||
|
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||||
|
* `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит
|
||||||
|
* от JVM-only `:memory-vector`.
|
||||||
|
*/
|
||||||
|
data class ScoredVector(
|
||||||
|
val id: String,
|
||||||
|
val score: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||||
|
* векторов к query. Метаданные заметок лежат в `MemoryStore` (для
|
||||||
|
* vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`);
|
||||||
|
* индекс хранит только embedding'и + id-маппинг.
|
||||||
|
*
|
||||||
|
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||||
|
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||||
|
* `:memory-md-vector` (KMP).
|
||||||
|
*
|
||||||
|
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||||
|
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации —
|
||||||
|
* это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||||
|
*/
|
||||||
|
interface MemoryVectorIndex : AutoCloseable {
|
||||||
|
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||||
|
val dimension: Int
|
||||||
|
|
||||||
|
/** Количество записей в индексе. */
|
||||||
|
suspend fun size(): Long
|
||||||
|
|
||||||
|
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
|
||||||
|
suspend fun add(id: String, embedding: FloatArray)
|
||||||
|
|
||||||
|
/** Удалить запись по [id]. Возвращает true если запись была. */
|
||||||
|
suspend fun remove(id: String): Boolean
|
||||||
|
|
||||||
|
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id. */
|
||||||
|
suspend fun search(
|
||||||
|
query: FloatArray,
|
||||||
|
k: Int,
|
||||||
|
filter: (MemoryNote) -> Boolean = { true },
|
||||||
|
): List<ScoredVector>
|
||||||
|
|
||||||
|
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
|
||||||
|
suspend fun flush()
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доп. контекст для vector-индекса: фильтр по категории и conversationId.
|
||||||
|
*
|
||||||
|
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с
|
||||||
|
* переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён
|
||||||
|
* сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`.
|
||||||
|
*
|
||||||
|
* Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые
|
||||||
|
* будущие) должны использовать этот хелпер при фильтрации результатов search,
|
||||||
|
* чтобы контракт был единый.
|
||||||
|
*/
|
||||||
|
fun noteMatches(
|
||||||
|
note: MemoryNote,
|
||||||
|
category: MemoryCategory? = null,
|
||||||
|
conversationId: String? = null,
|
||||||
|
): Boolean {
|
||||||
|
if (category != null && note.category != category) return false
|
||||||
|
if (conversationId != null && note.conversationId != conversationId) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pw.binom.agentik.memory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`.
|
||||||
|
*
|
||||||
|
* `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP),
|
||||||
|
* поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают
|
||||||
|
* честный suspend, а блокирующая работа уходит в background dispatcher.
|
||||||
|
*
|
||||||
|
* Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI
|
||||||
|
* text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем
|
||||||
|
* его; иначе — определяем лениво по первому [embed] (probe-vector на пустом
|
||||||
|
* тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования,
|
||||||
|
* так что для prod-использования рекомендуется всегда передавать [knownDimension]
|
||||||
|
* явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а).
|
||||||
|
*
|
||||||
|
* @param extractor underlying extractor (не null)
|
||||||
|
* @param knownDimension заранее известная размерность; null = определить по probe
|
||||||
|
*/
|
||||||
|
class TextEmbeddingExecutor(
|
||||||
|
val extractor: TextEmbeddingExtractor,
|
||||||
|
val knownDimension: Int? = null,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
/** Размерность векторов. Эффективно константа после первого обращения. */
|
||||||
|
val dimension: Int by lazy {
|
||||||
|
knownDimension ?: extractor.embed("").dim
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит
|
||||||
|
* в [Dispatchers.Default] — caller может безопасно await'ить.
|
||||||
|
*/
|
||||||
|
suspend fun embed(text: String): FloatArray =
|
||||||
|
withContext(Dispatchers.Default) { extractor.embed(text).values }
|
||||||
|
|
||||||
|
/** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */
|
||||||
|
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
||||||
|
texts.map { embed(it) }
|
||||||
|
|
||||||
|
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
|
||||||
|
override fun close() {
|
||||||
|
extractor.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// :memory-md-vector — гибридное хранилище памяти:
|
||||||
|
//
|
||||||
|
// .md файлы (:memory-md, single source of truth)
|
||||||
|
// ↓ reconcile() на старте
|
||||||
|
// sqlite vector index (ksqlite + sqlite-vec vec0, derived cache)
|
||||||
|
//
|
||||||
|
// `.md` — единственный источник правды по метаданным и тексту заметок.
|
||||||
|
// Вектора — derived cache, перестраивается на старте и при `upsert`/`delete`.
|
||||||
|
//
|
||||||
|
// ANN-поиск: vector KNN (sqlite-vec MATCH) → top-50 → keyword rerank
|
||||||
|
// через `MdMemoryFormat.keywordScore` (vector 0.7 + keyword 0.3).
|
||||||
|
//
|
||||||
|
// Цели сборки — KMP: jvm() + linuxX64() + mingwX64(). До 2026-09-21 был
|
||||||
|
// JVM-only, потому что тащил `EmbeddingProvider` из JVM-only `:memory-vector`.
|
||||||
|
// С переходом на `TextEmbeddingExecutor` (из `:memory-api`, который тянет
|
||||||
|
// `pw.binom.ai.embeddingtext:api` — теперь KMP) модуль стал платформо-
|
||||||
|
// независимым. Под нативом тесты работают с `FakeTextEmbeddingExtractor`;
|
||||||
|
// прод-реализация (`:siglip` модуль text-embedding-kmp) пока JVM+Android only.
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
linuxX64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.io.core)
|
||||||
|
|
||||||
|
api(project(":memory-api"))
|
||||||
|
implementation(project(":memory-md"))
|
||||||
|
// `text-embedding-api` тянется транзитивно через `:memory-api`
|
||||||
|
// (мы добавили `api(libs.text.embedding.api)` в memory-api/build.gradle.kts).
|
||||||
|
// Раньше тут стоял `implementation(project(":memory-vector"))` ради
|
||||||
|
// `EmbeddingProvider` — JVM-only модуль с JVector. Теперь не нужен.
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySearchResult
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.agentik.memory.md.MdMemoryFormat
|
||||||
|
import pw.binom.agentik.memory.md.MdMemoryStore
|
||||||
|
import pw.binom.agentik.memory.noteMatches
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Гибридное хранилище памяти:
|
||||||
|
*
|
||||||
|
* * `.md` файлы (через [MdMemoryStore]) — single source of truth по
|
||||||
|
* метаданным и тексту заметок;
|
||||||
|
* * ksqlite vector index ([KsqliteVectorIndex]) — derived cache embeddings
|
||||||
|
* и content_hash.
|
||||||
|
*
|
||||||
|
** Архитектурный контракт:
|
||||||
|
*
|
||||||
|
* 1. Любая мутация (upsert/delete) обновляет оба слоя атомарно: сначала
|
||||||
|
* `.md` (через [MdMemoryStore]), потом векторный кэш. Если vector-write
|
||||||
|
* упал — `.md` уже сохранён; reconcile при следующем старте восстановит
|
||||||
|
* консистентность.
|
||||||
|
*
|
||||||
|
* 2. [search] использует vector ANN (sqlite-vec MATCH) → top-50 → keyword
|
||||||
|
* rerank (`MdMemoryFormat.keywordScore`). Финальный score = 0.7 * vector
|
||||||
|
* + 0.3 * keyword. Это даёт семантический recall с быстрой фильтрацией
|
||||||
|
* по точным совпадениям.
|
||||||
|
*
|
||||||
|
* 3. [reconcile] — вызывается при старте (из [openHybridMemoryStore]):
|
||||||
|
* - .md файл есть, вектора нет → embed + add;
|
||||||
|
* - .md файл есть, вектор есть, content_hash отличается → re-embed;
|
||||||
|
* - .md файла нет, вектор есть → orphan, remove.
|
||||||
|
*
|
||||||
|
* 4. Read-only методы ([get], [list], [markUsed], [archiveStale], [events])
|
||||||
|
* делегируются в [MdMemoryStore] напрямую — никакой транзакции с
|
||||||
|
* vector-кэшем.
|
||||||
|
*
|
||||||
|
* Потокобезопасность: делегирующие методы — thread-safe за счёт
|
||||||
|
* `MdMemoryStore.mu`. Мутации векторов сериализуются
|
||||||
|
* [KsqliteVectorIndex.mutex]. Метод [reconcile] держит свой [mutex] для
|
||||||
|
* исключения конкурентных upsert'ов во время согласования.
|
||||||
|
*/
|
||||||
|
class HybridMdVectorStore internal constructor(
|
||||||
|
private val mdStore: MdMemoryStore,
|
||||||
|
private val vectorIndex: KsqliteVectorIndex,
|
||||||
|
private val embedder: TextEmbeddingExecutor,
|
||||||
|
) : MemoryStore {
|
||||||
|
|
||||||
|
private val reconcileMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отчёт о согласовании `.md` ↔ vector-индекс. Возвращается из [reconcile].
|
||||||
|
*/
|
||||||
|
data class ReconcileReport(
|
||||||
|
val added: Int,
|
||||||
|
val reembedded: Int,
|
||||||
|
val orphansRemoved: Int,
|
||||||
|
) {
|
||||||
|
val totalChanged: Int get() = added + reembedded + orphansRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Согласовать vector-кэш с текущим состоянием `.md` файлов.
|
||||||
|
*
|
||||||
|
* Идемпотентен — повторный вызов no-op.
|
||||||
|
*
|
||||||
|
* Можно вызывать из фонового потока при старте `Main.kt` чтобы
|
||||||
|
* залогировать "reconciled: 5 re-embedded, 2 added, 0 orphans".
|
||||||
|
*/
|
||||||
|
suspend fun reconcile(): ReconcileReport = reconcileMutex.withLock {
|
||||||
|
val onDisk: List<MemoryNote> = mdStore.list(limit = Int.MAX_VALUE)
|
||||||
|
val onDiskById: Map<String, MemoryNote> = onDisk.associateBy { it.id }
|
||||||
|
val inCache: List<KsqliteVectorIndex.MetaEntry> = vectorIndex.allMeta()
|
||||||
|
|
||||||
|
val cachedIds: Set<String> = inCache.map { it.id }.toSet()
|
||||||
|
|
||||||
|
var added = 0
|
||||||
|
var reembedded = 0
|
||||||
|
var orphansRemoved = 0
|
||||||
|
|
||||||
|
// 1) orphan-cleanup: vector есть, .md нет
|
||||||
|
for (cached in inCache) {
|
||||||
|
if (cached.id !in onDiskById) {
|
||||||
|
vectorIndex.remove(cached.id)
|
||||||
|
orphansRemoved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) re-embed / add
|
||||||
|
for (note in onDisk) {
|
||||||
|
val cached = inCache.firstOrNull { it.id == note.id }
|
||||||
|
val currentHash = note.contentHash()
|
||||||
|
if (cached == null) {
|
||||||
|
// .md есть, вектора нет → add
|
||||||
|
val vec = embedder.embed(note.content)
|
||||||
|
vectorIndex.add(note.id, vec, currentHash)
|
||||||
|
added++
|
||||||
|
} else if (cached.contentHash != currentHash) {
|
||||||
|
// .md изменился → re-embed
|
||||||
|
val vec = embedder.embed(note.content)
|
||||||
|
vectorIndex.add(note.id, vec, currentHash)
|
||||||
|
reembedded++
|
||||||
|
}
|
||||||
|
// else: cached.contentHash == currentHash → no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
ReconcileReport(added, reembedded, orphansRemoved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MemoryStore impl: мутации ─────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun upsert(note: MemoryNote) {
|
||||||
|
mdStore.upsert(note)
|
||||||
|
val vec = embedder.embed(note.content)
|
||||||
|
vectorIndex.add(note.id, vec, note.contentHash())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
val existed = mdStore.delete(id)
|
||||||
|
vectorIndex.remove(id)
|
||||||
|
return existed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MemoryStore impl: search (hybrid) ────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
|
||||||
|
if (query.query.isBlank()) return emptyList()
|
||||||
|
if (query.topK <= 0) return emptyList()
|
||||||
|
|
||||||
|
// Этап 1: vector ANN top-K (K=50 или больше topK).
|
||||||
|
val candidateK = maxOf(query.topK, VECTOR_CANDIDATES)
|
||||||
|
val qVec = embedder.embed(query.query)
|
||||||
|
val vectorHits = vectorIndex.search(qVec, candidateK)
|
||||||
|
|
||||||
|
// Этап 2: загружаем кандидатов из .md (single source of truth)
|
||||||
|
// mapNotNull не умеет suspend, поэтому собираем вручную.
|
||||||
|
val candidates: List<Pair<MemoryNote, Float>> = buildList(vectorHits.size) {
|
||||||
|
for (hit in vectorHits) {
|
||||||
|
val note = mdStore.get(hit.id) ?: continue
|
||||||
|
// Применяем категорийный/конво-фильтр ДО rerank — экономим keywordScore.
|
||||||
|
if (!noteMatches(note, query.category, query.conversationId)) continue
|
||||||
|
add(note to hit.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Этап 3: keyword rerank (vector 0.7 + keyword 0.3)
|
||||||
|
val rescored = candidates.map { (note, vecScore) ->
|
||||||
|
val kwScore = MdMemoryFormat.keywordScore(query.query, note)
|
||||||
|
val finalScore = vecScore * VECTOR_WEIGHT + kwScore * KEYWORD_WEIGHT
|
||||||
|
MemorySearchResult(note, finalScore)
|
||||||
|
}.sortedByDescending { it.score }
|
||||||
|
|
||||||
|
return if (rescored.size > query.topK) rescored.subList(0, query.topK) else rescored
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MemoryStore impl: read-only delegation ───────────────────────
|
||||||
|
|
||||||
|
override suspend fun get(id: String): MemoryNote? = mdStore.get(id)
|
||||||
|
|
||||||
|
override suspend fun list(
|
||||||
|
category: pw.binom.agentik.memory.MemoryCategory?,
|
||||||
|
conversationId: String?,
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<MemoryNote> = mdStore.list(category, conversationId, limit, offset)
|
||||||
|
|
||||||
|
override suspend fun markUsed(id: String, at: Instant) = mdStore.markUsed(id, at)
|
||||||
|
|
||||||
|
override suspend fun archiveStale(
|
||||||
|
maxAge: kotlin.time.Duration,
|
||||||
|
maxUseCount: Int,
|
||||||
|
now: Instant,
|
||||||
|
): Int {
|
||||||
|
// Вектор-кэш не хранит lastUsedAt/useCount (только content_hash).
|
||||||
|
// Делегируем в mdStore — он сам знает что удалять; vector удалится
|
||||||
|
// каскадно при archiveStale → delete loop ниже.
|
||||||
|
val deleted = mdStore.archiveStale(maxAge, maxUseCount, now)
|
||||||
|
// Дополнительно чистим vector-кэш от записей, которых больше нет в .md
|
||||||
|
val remaining = mdStore.list(limit = Int.MAX_VALUE).map { it.id }.toSet()
|
||||||
|
vectorIndex.allMeta().forEach { entry ->
|
||||||
|
if (entry.id !in remaining) vectorIndex.remove(entry.id)
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(): Flow<MemoryStoreEvent> = mdStore.events()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { vectorIndex.close() }
|
||||||
|
runCatching { mdStore.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VECTOR_WEIGHT: Float = 0.7f
|
||||||
|
const val KEYWORD_WEIGHT: Float = 0.3f
|
||||||
|
const val VECTOR_CANDIDATES: Int = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.agentik.memory.md.openMdMemory
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть гибридное хранилище памяти (`.md` + sqlite vector index).
|
||||||
|
*
|
||||||
|
* Создаёт:
|
||||||
|
* - [MdMemoryStore] на [memoryRoot] (`.md` файлы);
|
||||||
|
* - [KsqliteVectorIndex] на [vectorDbPath] (sqlite-vec vec0);
|
||||||
|
* - [HybridMdVectorStore] — обёртка с reconcile и hybrid search.
|
||||||
|
*
|
||||||
|
* Перед возвратом выполняет [HybridMdVectorStore.reconcile] — для свежей
|
||||||
|
* БД это приведёт к первичному embed'у всех `.md` файлов; для существующей —
|
||||||
|
* к re-embed'у изменившихся заметок и orphan-cleanup.
|
||||||
|
*
|
||||||
|
* @param memoryRoot директория с `.md` файлами (`USER.md`, `WORLD.md`, ...).
|
||||||
|
* @param vectorDbPath путь к файлу sqlite-БД для vector-кэша.
|
||||||
|
* @param dimension размерность embeddings от [embedder]. Фиксируется
|
||||||
|
* при создании индекса; дальнейшая смена = wipe БД.
|
||||||
|
* @param embedder провайдер embeddings.
|
||||||
|
* @param runReconcile выполнить [HybridMdVectorStore.reconcile] сразу после
|
||||||
|
* открытия. В тестах можно отключить для скорости.
|
||||||
|
*/
|
||||||
|
fun openHybridMemoryStore(
|
||||||
|
memoryRoot: Path,
|
||||||
|
vectorDbPath: Path,
|
||||||
|
dimension: Int,
|
||||||
|
embedder: TextEmbeddingExecutor,
|
||||||
|
runReconcile: Boolean = true,
|
||||||
|
): HybridMdVectorStore {
|
||||||
|
val md = openMdMemory(memoryRoot)
|
||||||
|
val conn = SQLiteConnection.open(vectorDbPath.toString())
|
||||||
|
Schema.migrate(conn, dimension)
|
||||||
|
val idx = KsqliteVectorIndex(conn, dimension)
|
||||||
|
val hybrid = HybridMdVectorStore(md, idx, embedder)
|
||||||
|
if (runReconcile) {
|
||||||
|
kotlinx.coroutines.runBlocking { hybrid.reconcile() }
|
||||||
|
}
|
||||||
|
return hybrid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory вариант для тестов: vector-кэш в `:memory:` sqlite,
|
||||||
|
* `.md` — в `/tmp/agentik-hybrid-test-{random}`.
|
||||||
|
*
|
||||||
|
* Используется POSIX-путь `/tmp`, потому что [System.getenv] / [System.getProperty]
|
||||||
|
* недоступны в KMP commonMain (только JVM). На Windows mingwX64 этот вызов
|
||||||
|
* упадёт — там тесты пока не предполагаются, нативные тесты только linuxX64.
|
||||||
|
* Под JVM `/tmp` либо есть как symlink (Linux/macOS), либо стоит использовать
|
||||||
|
* jvmTest-специфичный factory.
|
||||||
|
*/
|
||||||
|
fun openInMemoryHybridMemoryStore(
|
||||||
|
dimension: Int,
|
||||||
|
embedder: TextEmbeddingExecutor,
|
||||||
|
): HybridMdVectorStore {
|
||||||
|
val tmpDir = Path("/tmp/agentik-hybrid-test-${kotlin.random.Random.nextLong()}")
|
||||||
|
val md = openMdMemory(tmpDir)
|
||||||
|
val conn = SQLiteConnection.memory("hybrid-${kotlin.random.Random.nextLong()}")
|
||||||
|
Schema.migrate(conn, dimension)
|
||||||
|
val idx = KsqliteVectorIndex(conn, dimension)
|
||||||
|
return HybridMdVectorStore(md, idx, embedder)
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemorySystem
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.agentik.memory.md.KeywordMdPrefetcher
|
||||||
|
import pw.binom.agentik.memory.md.KeywordMdReviewer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Связка [HybridMdVectorStore] + keyword-prefetcher + keyword-reviewer.
|
||||||
|
*
|
||||||
|
* Store делегирует I/O между .md (single source of truth) и sqlite-vector-кэшем;
|
||||||
|
* prefetcher и reviewer работают по .md-данным (через [HybridMdVectorStore]),
|
||||||
|
* так что обе роли видят консистентное состояние.
|
||||||
|
*/
|
||||||
|
class HybridMemorySystem internal constructor(
|
||||||
|
override val store: MemoryStore,
|
||||||
|
override val prefetcher: MemoryPrefetcher,
|
||||||
|
override val reviewer: MemoryReviewer,
|
||||||
|
) : MemorySystem {
|
||||||
|
override fun close() = store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает [HybridMemorySystem] для указанной корневой директории + sqlite-БД.
|
||||||
|
*
|
||||||
|
* Под капотом: [HybridMdVectorStore] (md + vector), keyword-prefetcher из
|
||||||
|
* `:memory-md` (работает по store.api), keyword-reviewer без LLM —
|
||||||
|
* LLM-импл добавляется в `:standalone` поверх.
|
||||||
|
*/
|
||||||
|
fun openHybridMemorySystem(
|
||||||
|
memoryRoot: Path,
|
||||||
|
vectorDbPath: Path,
|
||||||
|
dimension: Int,
|
||||||
|
embedder: TextEmbeddingExecutor,
|
||||||
|
runReconcile: Boolean = true,
|
||||||
|
): HybridMemorySystem {
|
||||||
|
val store = openHybridMemoryStore(memoryRoot, vectorDbPath, dimension, embedder, runReconcile)
|
||||||
|
return HybridMemorySystem(
|
||||||
|
store = store,
|
||||||
|
prefetcher = KeywordMdPrefetcher(store),
|
||||||
|
reviewer = KeywordMdReviewer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemoryVectorIndex
|
||||||
|
import pw.binom.agentik.memory.ScoredVector
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ksqlite-реализация [MemoryVectorIndex] поверх `vec0` virtual table
|
||||||
|
* (sqlite-vec extension, встроен в ksqlite).
|
||||||
|
*
|
||||||
|
* Маппинг id → rowid:
|
||||||
|
* - TEXT `id` (== MemoryNote.id, "mem-...") лежит в [Schema.TABLE_META].
|
||||||
|
* - `vec0` индексирует по `rowid` (INTEGER auto-increment).
|
||||||
|
* - JOIN через `WHERE vec0.rowid = meta.rowid`.
|
||||||
|
*
|
||||||
|
* На каждое [add] с contentHash рядом с вектором пишется meta с
|
||||||
|
* content_hash от [MemoryNote.contentHash]. Это позволяет reconcile'у
|
||||||
|
* в [HybridMdVectorStore] определить "изменилась ли заметка" без re-embed.
|
||||||
|
*
|
||||||
|
* Поиск — `vec0` MATCH (cosine distance, sqlite-vec native). Score
|
||||||
|
* конвертируется из distance (0..2, меньше = ближе) в similarity
|
||||||
|
* (0..1, больше = ближе).
|
||||||
|
*
|
||||||
|
* Конкурентность: write-операции сериализуются [mutex]; read'ы
|
||||||
|
* (`size`/`search`) не блокируют.
|
||||||
|
*/
|
||||||
|
class KsqliteVectorIndex internal constructor(
|
||||||
|
private val conn: SQLiteConnection,
|
||||||
|
override val dimension: Int,
|
||||||
|
) : MemoryVectorIndex {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val insertVec: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"INSERT INTO ${Schema.TABLE_VECTORS}(${Schema.COL_VECTOR}) VALUES (?)"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val lastInsertRowIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT last_insert_rowid()"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val insertMeta: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO ${Schema.TABLE_META}
|
||||||
|
(${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH},
|
||||||
|
${Schema.COL_DIMENSION}, ${Schema.COL_UPDATED_AT})
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val findMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Запрос `... WHERE rowid IN (?, ?, ...)`. Подготавливаем на N=$MAX_INLINE_ROWIDS параметров. */
|
||||||
|
private val findMetaByRowIdsStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
(1..MAX_INLINE_ROWIDS).joinToString(
|
||||||
|
separator = ",",
|
||||||
|
prefix = "SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID} FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} IN (",
|
||||||
|
postfix = ")",
|
||||||
|
) { "?" }
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
private val deleteByRowIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_VECTORS} WHERE rowid = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val deleteMetaByRowIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ROWID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val deleteMetaByIdStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"DELETE FROM ${Schema.TABLE_META} WHERE ${Schema.COL_ID} = ?"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val sizeMetaStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT COUNT(*) FROM ${Schema.TABLE_META}"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val allMetaStmt: SQLitePreparedStatement = conn.prepare(
|
||||||
|
"SELECT ${Schema.COL_ROWID}, ${Schema.COL_ID}, ${Schema.COL_HASH} FROM ${Schema.TABLE_META}"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sqlite-vec требует чтобы `LIMIT` в MATCH-запросе был integer-литералом,
|
||||||
|
* а не `?`. Поэтому для search используем динамическую подготовку
|
||||||
|
* (кешированную по [k]).
|
||||||
|
*
|
||||||
|
* Vec0 KNN check (`sqlite-vec` source): «A LIMIT or 'k = ?' constraint is
|
||||||
|
* required on vec0 knn queries». Имя параметра `:k` тоже поддерживается,
|
||||||
|
* но у ksqlite bind API — только позиционный; literal проще.
|
||||||
|
*/
|
||||||
|
private val searchCache = HashMap<Int, SQLitePreparedStatement>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выдать rowid для существующей записи или -1 если нет.
|
||||||
|
*
|
||||||
|
* **ВАЖНО**: вызывающий ОБЯЗАН держать [mutex]. Этот метод НЕ
|
||||||
|
* reentrant — повторный вход в [Mutex.withLock] приведёт к
|
||||||
|
* deadlock (kotlinx.coroutines.sync.Mutex не reentrant).
|
||||||
|
*/
|
||||||
|
private fun findRowIdLocked(id: String): Long {
|
||||||
|
findMetaByIdStmt.reset()
|
||||||
|
findMetaByIdStmt.clearBindings()
|
||||||
|
findMetaByIdStmt.bindText(1, id)
|
||||||
|
findMetaByIdStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0) ?: -1L
|
||||||
|
}
|
||||||
|
return -1L
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun size(): Long = mutex.withLock {
|
||||||
|
sizeMetaStmt.reset()
|
||||||
|
sizeMetaStmt.clearBindings()
|
||||||
|
sizeMetaStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) rs.getLong(0) ?: 0L else 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun add(id: String, embedding: FloatArray) {
|
||||||
|
require(embedding.size == dimension) {
|
||||||
|
"embedding dim=${embedding.size} != index dim=$dimension"
|
||||||
|
}
|
||||||
|
add(id, embedding, contentHash = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить или обновить запись с явным content_hash.
|
||||||
|
* Если запись с таким id уже есть — обновляет и вектор, и meta.
|
||||||
|
* Иначе — создаёт новый rowid.
|
||||||
|
*/
|
||||||
|
suspend fun add(id: String, embedding: FloatArray, contentHash: String) {
|
||||||
|
require(embedding.size == dimension) {
|
||||||
|
"embedding dim=${embedding.size} != index dim=$dimension"
|
||||||
|
}
|
||||||
|
mutex.withLock {
|
||||||
|
val existingRowId = findRowIdLocked(id)
|
||||||
|
val rowId: Long = if (existingRowId > 0) {
|
||||||
|
// Update: заменяем вектор по существующему rowid
|
||||||
|
insertVec.reset()
|
||||||
|
insertVec.clearBindings()
|
||||||
|
insertVec.bindVector(1, embedding)
|
||||||
|
insertVec.executeUpdate()
|
||||||
|
existingRowId
|
||||||
|
} else {
|
||||||
|
// Insert: получаем свежий rowid
|
||||||
|
insertVec.reset()
|
||||||
|
insertVec.clearBindings()
|
||||||
|
insertVec.bindVector(1, embedding)
|
||||||
|
insertVec.executeUpdate()
|
||||||
|
lastInsertRowIdStmt.reset()
|
||||||
|
lastInsertRowIdStmt.clearBindings()
|
||||||
|
lastInsertRowIdStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) rs.getLong(0) ?: error("no last_insert_rowid()") else error("no last_insert_rowid()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertMeta.reset()
|
||||||
|
insertMeta.clearBindings()
|
||||||
|
insertMeta.bindLong(1, rowId)
|
||||||
|
insertMeta.bindText(2, id)
|
||||||
|
insertMeta.bindText(3, contentHash)
|
||||||
|
insertMeta.bindLong(4, dimension.toLong())
|
||||||
|
insertMeta.bindLong(5, Clock.System.now().toEpochMilliseconds())
|
||||||
|
insertMeta.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(id: String): Boolean = mutex.withLock {
|
||||||
|
val rowId = findRowIdLocked(id)
|
||||||
|
if (rowId <= 0) return@withLock false
|
||||||
|
|
||||||
|
// Удаляем meta сначала — иначе orphan-row в vec0.
|
||||||
|
deleteMetaByIdStmt.reset()
|
||||||
|
deleteMetaByIdStmt.clearBindings()
|
||||||
|
deleteMetaByIdStmt.bindText(1, id)
|
||||||
|
deleteMetaByIdStmt.executeUpdate()
|
||||||
|
|
||||||
|
deleteByRowIdStmt.reset()
|
||||||
|
deleteByRowIdStmt.clearBindings()
|
||||||
|
deleteByRowIdStmt.bindLong(1, rowId)
|
||||||
|
deleteByRowIdStmt.executeUpdate()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прочитать content_hash для id. null если записи нет. */
|
||||||
|
suspend fun getContentHash(id: String): String? = mutex.withLock {
|
||||||
|
findMetaByIdStmt.reset()
|
||||||
|
findMetaByIdStmt.clearBindings()
|
||||||
|
findMetaByIdStmt.bindText(1, id)
|
||||||
|
findMetaByIdStmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) rs.getText(1) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полный список (rowid, id, contentHash) для reconcile'а. */
|
||||||
|
suspend fun allMeta(): List<MetaEntry> = mutex.withLock {
|
||||||
|
allMetaStmt.reset()
|
||||||
|
allMetaStmt.clearBindings()
|
||||||
|
val out = mutableListOf<MetaEntry>()
|
||||||
|
allMetaStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
val rowId = rs.getLong(0) ?: continue
|
||||||
|
val id = rs.getText(1) ?: continue
|
||||||
|
val hash = rs.getText(2) ?: continue
|
||||||
|
out.add(MetaEntry(rowId, id, hash))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(
|
||||||
|
query: FloatArray,
|
||||||
|
k: Int,
|
||||||
|
filter: (MemoryNote) -> Boolean,
|
||||||
|
): List<ScoredVector> {
|
||||||
|
require(query.size == dimension) {
|
||||||
|
"query dim=${query.size} != index dim=$dimension"
|
||||||
|
}
|
||||||
|
val safeK = k.coerceAtLeast(1)
|
||||||
|
return mutex.withLock {
|
||||||
|
// sqlite-vec MATCH требует минимальный запрос без JOIN/лишних
|
||||||
|
// ORDER BY — иначе "A LIMIT or 'k = ?' constraint is required".
|
||||||
|
// Поэтому делаем два запроса:
|
||||||
|
// 1) vec0 ANN → (rowid, distance)
|
||||||
|
// 2) meta lookup по собранным rowid → id
|
||||||
|
val stmt = searchCache.getOrPut(safeK) {
|
||||||
|
conn.prepare(
|
||||||
|
"""
|
||||||
|
SELECT rowid, distance
|
||||||
|
FROM ${Schema.TABLE_VECTORS}
|
||||||
|
WHERE ${Schema.COL_VECTOR} MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT $safeK
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stmt.reset()
|
||||||
|
stmt.clearBindings()
|
||||||
|
stmt.bindVector(1, query)
|
||||||
|
|
||||||
|
val candidates = mutableListOf<Pair<Long, Float>>()
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
val rowId = rs.getLong(0) ?: continue
|
||||||
|
val distance = rs.getDouble(1) ?: continue
|
||||||
|
val score = ((1.0 - distance / 2.0) * 1.0).toFloat().coerceIn(0f, 1f)
|
||||||
|
candidates.add(rowId to score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) return@withLock emptyList<ScoredVector>()
|
||||||
|
|
||||||
|
// 2-й запрос: meta по списку rowid.
|
||||||
|
val rowIds = candidates.map { it.first }
|
||||||
|
val idByRowId = HashMap<Long, String>(candidates.size)
|
||||||
|
findMetaByRowIdsStmt.reset()
|
||||||
|
findMetaByRowIdsStmt.clearBindings()
|
||||||
|
for ((idx, rowId) in rowIds.withIndex()) {
|
||||||
|
findMetaByRowIdsStmt.bindLong(idx + 1, rowId)
|
||||||
|
}
|
||||||
|
findMetaByRowIdsStmt.executeQuery().use { rs ->
|
||||||
|
while (rs.next()) {
|
||||||
|
val rowId = rs.getLong(0) ?: continue
|
||||||
|
val id = rs.getText(1) ?: continue
|
||||||
|
idByRowId[rowId] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.mapNotNull { (rowId, score) ->
|
||||||
|
idByRowId[rowId]?.let { ScoredVector(it, score) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
// ksqlite + WAL — flush не нужен. Метод для совместимости с интерфейсом.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
insertVec.close()
|
||||||
|
lastInsertRowIdStmt.close()
|
||||||
|
insertMeta.close()
|
||||||
|
findMetaByIdStmt.close()
|
||||||
|
deleteByRowIdStmt.close()
|
||||||
|
deleteMetaByRowIdStmt.close()
|
||||||
|
deleteMetaByIdStmt.close()
|
||||||
|
sizeMetaStmt.close()
|
||||||
|
allMetaStmt.close()
|
||||||
|
searchCache.values.forEach { it.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MetaEntry(val rowId: Long, val id: String, val contentHash: String)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Максимум rowid, которые мы зашиваем в `IN (?,?,...)` одним prepared statement'ом. */
|
||||||
|
const val MAX_INLINE_ROWIDS = 256
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL/DML для ksqlite-бэкенда `:memory-md-vector`.
|
||||||
|
*
|
||||||
|
* Две таблицы:
|
||||||
|
*
|
||||||
|
* * `memory_vectors` (vec0) — ANN-индекс. Содержит embedding + первичный
|
||||||
|
* ключ `rowid` (auto-increment INTEGER, sqlite-vec требует именно его).
|
||||||
|
* Размерность задаётся `float[DIMENSION]` при создании.
|
||||||
|
*
|
||||||
|
* * `memory_meta` — рядом с вектором: TEXT `id` (== MemoryNote.id) +
|
||||||
|
* `rowid` (тот же, что в vec0) + `content_hash` (от MemoryNote.contentHash()).
|
||||||
|
* Используется reconcile'ом — если хэш в meta не совпадает с тем, что
|
||||||
|
* вычисляется из текущего `.md` файла → re-embed.
|
||||||
|
*
|
||||||
|
* JOIN между vec0 и meta: `WHERE vec0.rowid = meta.rowid`.
|
||||||
|
*
|
||||||
|
* Миграция через `PRAGMA user_version` (как в `:journal-ksqlite/Schema.kt`).
|
||||||
|
*/
|
||||||
|
internal object Schema {
|
||||||
|
|
||||||
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
|
const val TABLE_VECTORS = "memory_vectors"
|
||||||
|
const val TABLE_META = "memory_meta"
|
||||||
|
|
||||||
|
const val COL_ID = "id"
|
||||||
|
const val COL_ROWID = "rowid"
|
||||||
|
const val COL_VECTOR = "embedding"
|
||||||
|
const val COL_HASH = "content_hash"
|
||||||
|
const val COL_DIMENSION = "dimension"
|
||||||
|
const val COL_UPDATED_AT = "updated_at"
|
||||||
|
|
||||||
|
fun v1Ddl(dimension: Int): String = """
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS $TABLE_VECTORS USING vec0(
|
||||||
|
$COL_VECTOR float[$dimension]
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS $TABLE_META (
|
||||||
|
$COL_ROWID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
$COL_ID TEXT NOT NULL UNIQUE,
|
||||||
|
$COL_HASH TEXT NOT NULL,
|
||||||
|
$COL_DIMENSION INTEGER NOT NULL,
|
||||||
|
$COL_UPDATED_AT INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meta_id ON $TABLE_META($COL_ID);
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
fun migrate(conn: SQLiteConnection, dimension: Int) {
|
||||||
|
val current = readUserVersion(conn)
|
||||||
|
if (current >= CURRENT_VERSION) return
|
||||||
|
|
||||||
|
conn.exec("BEGIN")
|
||||||
|
try {
|
||||||
|
if (current < 1) {
|
||||||
|
conn.exec(v1Ddl(dimension))
|
||||||
|
}
|
||||||
|
writeUserVersion(conn, CURRENT_VERSION)
|
||||||
|
conn.exec("COMMIT")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { conn.exec("ROLLBACK") }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readUserVersion(conn: SQLiteConnection): Int {
|
||||||
|
conn.prepare("PRAGMA user_version").use { stmt ->
|
||||||
|
stmt.executeQuery().use { rs ->
|
||||||
|
if (rs.next()) return rs.getLong(0)?.toInt() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUserVersion(conn: SQLiteConnection, version: Int) {
|
||||||
|
conn.exec("PRAGMA user_version = $version")
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package pw.binom.agentik.memory.mdvector
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.runBlocking as kRunBlocking
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты гибридного стора: делегирование в .md, vector ANN, reconcile,
|
||||||
|
* hybrid search rerank.
|
||||||
|
*
|
||||||
|
* Используется [kRunBlocking] (а не `runTest`) потому что `MdMemoryStore`
|
||||||
|
* делает реальный файловый I/O (`kotlinx-io`), который плохо дружит с
|
||||||
|
* TestDispatcher'ом — `runTest` зависает на virtual-time I/O.
|
||||||
|
*/
|
||||||
|
class HybridMdVectorStoreTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детерминированный [TextEmbeddingExtractor] для тестов модуля.
|
||||||
|
* Хеширует текст в псевдо-вектор фиксированной размерности, L2-normalize.
|
||||||
|
* Заворачивается в [TextEmbeddingExecutor] с пред-объявленной размерностью.
|
||||||
|
*/
|
||||||
|
private fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor =
|
||||||
|
TextEmbeddingExecutor(FakeTestExtractor(dimension), knownDimension = dimension)
|
||||||
|
|
||||||
|
private class FakeTestExtractor(val dim: Int) : TextEmbeddingExtractor {
|
||||||
|
override fun embed(text: String): TextEmbedding {
|
||||||
|
val v = FloatArray(dim)
|
||||||
|
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
||||||
|
for (i in 0 until dim) {
|
||||||
|
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||||
|
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||||
|
}
|
||||||
|
var norm = 0f
|
||||||
|
for (x in v) norm += x * x
|
||||||
|
norm = kotlin.math.sqrt(norm)
|
||||||
|
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||||
|
return TextEmbedding(v)
|
||||||
|
}
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun note(
|
||||||
|
id: String,
|
||||||
|
content: String,
|
||||||
|
category: MemoryCategory = MemoryCategory.USER,
|
||||||
|
source: MemorySource = MemorySource.USER_EXPLICIT,
|
||||||
|
) = MemoryNote(
|
||||||
|
id = id,
|
||||||
|
category = category,
|
||||||
|
content = content,
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||||
|
lastUsedAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||||
|
useCount = 0,
|
||||||
|
conversationId = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun upsertWritesToBothMdAndVectorCache() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-1", "user prefers dark mode"))
|
||||||
|
|
||||||
|
val fromMd = store.get("mem-1")
|
||||||
|
assertNotNull(fromMd)
|
||||||
|
assertEquals("user prefers dark mode", fromMd.content)
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "user prefers dark mode", topK = 5))
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
assertEquals("mem-1", hits.first().note.id)
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesFromBothLayers() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-2", "lives in Saint Petersburg"))
|
||||||
|
store.upsert(note("mem-3", "loves Kotlin multiplatform"))
|
||||||
|
|
||||||
|
assertEquals(2, store.list(limit = 10).size)
|
||||||
|
|
||||||
|
val removed = store.delete("mem-2")
|
||||||
|
assertTrue(removed)
|
||||||
|
|
||||||
|
assertEquals(1, store.list(limit = 10).size)
|
||||||
|
assertNull(store.get("mem-2"))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "Saint Petersburg", topK = 5))
|
||||||
|
assertTrue(hits.isEmpty() || hits.all { it.note.id != "mem-2" })
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconcileOnEmptyStoreIsNoOp() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
val report = store.reconcile()
|
||||||
|
assertEquals(0, report.added)
|
||||||
|
assertEquals(0, report.reembedded)
|
||||||
|
assertEquals(0, report.orphansRemoved)
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconcileIsIdempotent() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-10", "works at Binom"))
|
||||||
|
val report = store.reconcile()
|
||||||
|
assertEquals(0, report.totalChanged, "идемпотентность reconcile: повторный вызов no-op")
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchReturnsRelevantResultsByKeyword() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-a", "kotlin multiplatform"))
|
||||||
|
store.upsert(note("mem-b", "java enterprise"))
|
||||||
|
store.upsert(note("mem-c", "kotlin coroutines"))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 5))
|
||||||
|
val ids = hits.map { it.note.id }.toSet()
|
||||||
|
assertTrue("mem-a" in ids, "expected 'kotlin multiplatform' in results: $ids")
|
||||||
|
assertTrue("mem-c" in ids, "expected 'kotlin coroutines' in results: $ids")
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchRespectsCategoryFilter() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("user-1", "kotlin lover", MemoryCategory.USER))
|
||||||
|
store.upsert(note("world-1", "kotlin 2.0 released", MemoryCategory.WORLD))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "kotlin", topK = 10, category = MemoryCategory.USER))
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
assertEquals("user-1", hits.first().note.id)
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hybridScoreCombinesVectorAndKeyword() = kRunBlocking {
|
||||||
|
val store = openInMemoryHybridMemoryStore(dimension = 32, embedder = fakeEmbeddingExecutor(32))
|
||||||
|
store.upsert(note("mem-x", "kotlin multiplatform project"))
|
||||||
|
|
||||||
|
val hits = store.search(MemorySearchQuery(query = "kotlin multiplatform", topK = 1))
|
||||||
|
assertEquals(1, hits.size)
|
||||||
|
val score = hits.first().score
|
||||||
|
assertTrue(score in 0f..1f, "score $score out of range")
|
||||||
|
assertTrue(score > 0.5f, "expected hybrid score > 0.5, got $score")
|
||||||
|
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
|
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
|
||||||
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не
|
// не подключаются. Раньше был нужен потому что text-embedding-kmp был
|
||||||
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
|
// только в локальном ~/.m2; с 2026-09-21 (v4 в caffeine) можно убрать,
|
||||||
|
// но оставлен на случай если CI внезапно отвалится от Nexus.
|
||||||
// Использование:
|
// Использование:
|
||||||
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
||||||
// Локальная разработка без флага — зависимости подключаются как обычно.
|
// Локальная разработка без флага — зависимости подключаются как обычно.
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package pw.binom.agentik.memory.vector
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||||
|
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детерминированный [TextEmbeddingExtractor] для тестов: хеширует текст в
|
||||||
|
* псевдо-вектор фиксированной размерности. L2-normalize чтобы cosine
|
||||||
|
* работал осмысленно.
|
||||||
|
*
|
||||||
|
* НЕ suspend, как и положено extractor'у — suspend-обёртка живёт в
|
||||||
|
* [TextEmbeddingExecutor] (используется в VectorMemoryStore через
|
||||||
|
* [fakeExecutor]).
|
||||||
|
*/
|
||||||
|
class FakeTextEmbeddingExtractor(
|
||||||
|
val dimension: Int = 32,
|
||||||
|
) : TextEmbeddingExtractor {
|
||||||
|
override fun embed(text: String): TextEmbedding {
|
||||||
|
val v = FloatArray(dimension)
|
||||||
|
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
||||||
|
for (i in 0 until dimension) {
|
||||||
|
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||||
|
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||||
|
}
|
||||||
|
var norm = 0f
|
||||||
|
for (x in v) norm += x * x
|
||||||
|
norm = kotlin.math.sqrt(norm)
|
||||||
|
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||||
|
return TextEmbedding(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удобная обёртка для тестов: создаёт `FakeTextEmbeddingExtractor` и
|
||||||
|
* сразу заворачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью.
|
||||||
|
*/
|
||||||
|
fun fakeEmbeddingExecutor(dimension: Int = 32): TextEmbeddingExecutor =
|
||||||
|
TextEmbeddingExecutor(FakeTextEmbeddingExtractor(dimension), knownDimension = dimension)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-события уровня агента: изменения в множестве диалогов
|
||||||
|
* (создание, удаление, переименование). События, происходящие **внутри**
|
||||||
|
* конкретного диалога, приходят через `Conversation.events` (live-stream
|
||||||
|
* per-turn Event'ов), а не сюда.
|
||||||
|
*
|
||||||
|
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
|
||||||
|
* идентична `OutboxStore.events`: поток **не реплеит** прошлое, для бэкфилла
|
||||||
|
* используются `Agent.getConversations` / `getConversation`.
|
||||||
|
*
|
||||||
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.AgentEvent`;
|
||||||
|
* typealias удалён 2026-09-21 (стирал nested-типы в `is`/`when`) — потребители
|
||||||
|
* импортируют напрямую из `pw.binom.agentik.outbox.AgentEvent`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface AgentEvent {
|
||||||
|
/** Момент эмиссии события в UTC. */
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создан новый диалог. Передаётся его id — handle можно получить через
|
||||||
|
* `Agent.getConversation`. Подписчик после [Created] может сразу открыть
|
||||||
|
* live-подписку на этот диалог через `Conversation.events`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("created")
|
||||||
|
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диалог удалён. Переданный `Conversation`-handle реализация обязана
|
||||||
|
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
|
||||||
|
* пользоваться handle нельзя.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("deleted")
|
||||||
|
data class Deleted(override val date: Instant, val id: String) : AgentEvent
|
||||||
|
|
||||||
|
/** У диалога сменился заголовок. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("renamed")
|
||||||
|
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновлён `updatedAt` диалога (после `send()` или другого события,
|
||||||
|
* бампнувшего активность). Клиентский кэш [ConversationStore] может
|
||||||
|
* применить этот event для пересортировки списка.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("touched")
|
||||||
|
data class Touched(override val date: Instant, val id: String, val updatedAt: Instant) : AgentEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified wrapper for all agent events in a single stream.
|
||||||
|
*
|
||||||
|
* Useful for admin dashboards, debug tools, parent agents: one subscription
|
||||||
|
* instead of N+1. For regular UI use two separate SSE feeds
|
||||||
|
* ([AgentEvent] via `/events` и [Event] via `/conversations/{id}/events`);
|
||||||
|
* [CommonEvent] — for those who need everything in one place.
|
||||||
|
*
|
||||||
|
* Server endpoint: `GET /events/all` (SSE), or replay via `OutboxStore.events(after)`.
|
||||||
|
*
|
||||||
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.CommonEvent`;
|
||||||
|
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для backward-compat,
|
||||||
|
* но он стирал nested-типы (`CommonEvent.Agent`, `CommonEvent.Conversation`),
|
||||||
|
* что ломало `is CommonEvent.Agent` на стороне клиента. Typealias'ы
|
||||||
|
* `Event`/`AgentEvent`/`CommonEvent` из `:proto` удалены — потребители
|
||||||
|
* импортируют напрямую из `pw.binom.agentik.outbox.*`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface CommonEvent {
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("agent")
|
||||||
|
data class Agent(
|
||||||
|
override val date: Instant,
|
||||||
|
val event: AgentEvent,
|
||||||
|
) : CommonEvent
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("conversation")
|
||||||
|
data class Conversation(
|
||||||
|
override val date: Instant,
|
||||||
|
val conversationId: String,
|
||||||
|
val event: Event,
|
||||||
|
) : CommonEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package pw.binom.agentik.outbox
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент live-потока `Conversation.events(after)`.
|
||||||
|
*
|
||||||
|
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
|
||||||
|
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
|
||||||
|
* порядка при равных timestamps.
|
||||||
|
*
|
||||||
|
* Базовая структура хода:
|
||||||
|
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
|
||||||
|
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
|
||||||
|
*
|
||||||
|
* **История**: до 2026-09-21 жил в `:proto` как `pw.binom.agentik.proto.Event`;
|
||||||
|
* при миграции в `:outbox-api` был оставлен typealias в `:proto` для
|
||||||
|
* backward-compat, но он стирал nested-типы (`Event.End`, `Event.ToolCall`,
|
||||||
|
* `Event.ToolResult`), что ломало `is Event.End` на стороне клиента.
|
||||||
|
* Typealias удалён 2026-09-21 — потребители импортируют напрямую из
|
||||||
|
* `pw.binom.agentik.outbox.Event`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface Event {
|
||||||
|
/** Момент эмиссии события в UTC. */
|
||||||
|
val date: Instant
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ResponseType {
|
||||||
|
@SerialName("text") TEXT,
|
||||||
|
@SerialName("image") IMAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("start_reasoning")
|
||||||
|
data class StartReasoning(override val date: Instant) : Event
|
||||||
|
|
||||||
|
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("start_response")
|
||||||
|
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
|
||||||
|
|
||||||
|
/** Ход завершён нормально. Соответствующий `Message.AssistantMessage` появится в `getMessages`. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("end")
|
||||||
|
data class End(override val date: Instant) : Event
|
||||||
|
|
||||||
|
/** Ход прерван через `Conversation.interrupt`. Частичный ответ НЕ сохраняется в истории. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("interrupted")
|
||||||
|
data class Interrupted(override val date: Instant) : Event
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append_text")
|
||||||
|
data class AppendText(override val date: Instant, val body: String) : Event
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append_image")
|
||||||
|
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
|
||||||
|
* [id] совпадает с id соответствующего `Message.ToolCall` в истории
|
||||||
|
* после завершения хода.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_call")
|
||||||
|
data class ToolCall(
|
||||||
|
override val date: Instant,
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val toolName: String,
|
||||||
|
val toolArgs: String,
|
||||||
|
) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат вызова тула. Приходит целиком после завершения исполнения.
|
||||||
|
*
|
||||||
|
* [toolCallId] = id [ToolCall], к которому относится результат, и
|
||||||
|
* `MessageRecord.ToolResult.toolCallId` в истории. Один Call → один Result,
|
||||||
|
* пара `(date, toolCallId)` уникальна — отдельный `id` в live-событии
|
||||||
|
* не нужен (PK живёт в персистентном журнале).
|
||||||
|
*
|
||||||
|
* [toolName] денормализован из соответствующего [ToolCall.toolName] —
|
||||||
|
* UI рендерит имя тула без локальной `Map<toolCallId, name>` и без риска
|
||||||
|
* «Result пришёл до Call». `null` допустим для backfill'а старых
|
||||||
|
* записей, у которых поле отсутствует, или теоретического случая
|
||||||
|
* Result без предшествующего Call (orphan).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("tool_result")
|
||||||
|
data class ToolResult(
|
||||||
|
override val date: Instant,
|
||||||
|
val toolCallId: String,
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String?,
|
||||||
|
) : Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибка хода. После неё поток завершается; дальнейшие события могут
|
||||||
|
* прийти, но ход считается проваленным.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("error")
|
||||||
|
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
|
||||||
|
}
|
||||||
@@ -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, инициализация движка или иная отказоустойчивая
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API для хранения self-reflection записей агента. Не зависит от
|
||||||
|
// journal/context/outbox — reflection это отдельная сущность (рантайм-мета
|
||||||
|
// о качестве последних ходов).
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kotlinx.serialization.core)
|
||||||
|
api(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package pw.binom.agentik.reflection
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-reflection запись — что агент "думает" о качестве своих последних ходов.
|
||||||
|
*
|
||||||
|
* - [score]: 1..5 (самооценка качества)
|
||||||
|
* - [weakSpots]: конкретные слабые места ("медленно ищу в Y", "путаю A и B")
|
||||||
|
* - [summary]: свободный комментарий в markdown (что заметил, что улучшить)
|
||||||
|
*
|
||||||
|
* Источник: one-shot LLM-размышление после каждых N ходов (см. AGENTIK_REFLECTION_INTERVAL).
|
||||||
|
* Используется в `ChatAgent.buildSystemPrompt` как "слабые места за последнее время".
|
||||||
|
*/
|
||||||
|
data class Reflection(
|
||||||
|
val id: String,
|
||||||
|
val conversationId: String?,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val turnsAnalyzed: Int,
|
||||||
|
val score: Int,
|
||||||
|
val summary: String,
|
||||||
|
val weakSpots: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хранилище рефлексий. Backed by SQLDelight `reflection` таблицу (impl в
|
||||||
|
* `:storage-sqlite`) или in-memory (impl в `:storage-inmemory`).
|
||||||
|
*
|
||||||
|
* Рефлексии — append-only: старые записи удаляются [deleteOlderThan] (cleanup)
|
||||||
|
* или архивируются через [Curator]-подобный процесс, но не редактируются.
|
||||||
|
*/
|
||||||
|
interface ReflectionStore : AutoCloseable {
|
||||||
|
suspend fun insert(reflection: Reflection)
|
||||||
|
suspend fun get(id: String): Reflection?
|
||||||
|
/** Самые свежие рефлексии (по всему агенту). */
|
||||||
|
suspend fun listRecent(limit: Int = 10): List<Reflection>
|
||||||
|
/** Рефлексии для конкретного диалога. */
|
||||||
|
suspend fun listForConversation(conversationId: String, limit: Int = 10): List<Reflection>
|
||||||
|
suspend fun deleteOlderThan(cutoff: Instant)
|
||||||
|
suspend fun count(): Int
|
||||||
|
|
||||||
|
/** Стрим новых рефлексий для подписчиков (для UI в будущем). */
|
||||||
|
fun events(): Flow<ReflectionEvent> = kotlinx.coroutines.flow.emptyFlow()
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ReflectionEvent {
|
||||||
|
data class Created(val reflection: Reflection) : ReflectionEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генератор id для reflection-записей. Префикс `refl-` чтобы в логах и
|
||||||
|
* БД-схемах было видно сразу.
|
||||||
|
*/
|
||||||
|
object Ids {
|
||||||
|
fun new(): String = "refl-${kotlin.uuid.Uuid.random()}"
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ dependencyResolutionManagement {
|
|||||||
mavenLocal()
|
mavenLocal()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
google()
|
google()
|
||||||
// Локально опубликованные snapshot-ы text-embedding-kmp (см. `~/.m2`).
|
// Home Nexus, репо "caffeine": pw.binom.* (a2a, text-embedding-kmp с v4,
|
||||||
// Home Nexus, репо "caffeine": pw.binom.* (A2A, ...)
|
// ksqlite до v0.1.1-SNAPSHOT — теперь в Maven Central, ...)
|
||||||
maven {
|
maven {
|
||||||
name = "caffeine"
|
name = "caffeine"
|
||||||
url = uri(caffeineRepo)
|
url = uri(caffeineRepo)
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CI-флаг: при -PskipVectorMemory=true :memory-vector не подключается как
|
// CI-флаг: при -PskipVectorMemory=true :memory-vector не подключается как
|
||||||
// зависимость — нужно для CI runner'а (text-embedding-kmp ещё не опубликован
|
// зависимость — нужно для CI runner'а (text-embedding-kmp:api/siglip-jvm
|
||||||
// в caffeine). Подробнее см. memory-vector/build.gradle.kts.
|
// опубликованы в caffeine начиная с v4, 2026-09-21). Подробнее см.
|
||||||
|
// memory-vector/build.gradle.kts.
|
||||||
val skipVectorMemory: Boolean =
|
val skipVectorMemory: Boolean =
|
||||||
(project.findProperty("skipVectorMemory") == "true") ||
|
(project.findProperty("skipVectorMemory") == "true") ||
|
||||||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
|||||||
val reply = StringBuilder()
|
val reply = StringBuilder()
|
||||||
val turnDone = CompletableDeferred<Unit>()
|
val turnDone = CompletableDeferred<Unit>()
|
||||||
val subscription = async {
|
val subscription = async {
|
||||||
conv.events(since).collect { e ->
|
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
||||||
|
val e = ce.event
|
||||||
when (e) {
|
when (e) {
|
||||||
is Event.AppendText -> reply.append(e.body)
|
is Event.AppendText -> reply.append(e.body)
|
||||||
is Event.End, is Event.Interrupted -> turnDone.complete(Unit)
|
is Event.End, is Event.Interrupted -> turnDone.complete(Unit)
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ private fun runServer() {
|
|||||||
|
|
||||||
val agent = ChatAgent(
|
val agent = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
conversationStore = sqliteStores.conversations,
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
messageStore = sqliteStores.messages,
|
messageStore = sqliteStores.messages,
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
reflectionStore = sqliteStores.reflections,
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.emitAll
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -22,14 +17,13 @@ import pw.binom.agentik.outbox.MutableOutboxStore
|
|||||||
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.Conversation as ProtoConversation
|
import pw.binom.agentik.proto.Conversation as ProtoConversation
|
||||||
import pw.binom.agentik.proto.Event as ProtoEvent
|
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
import pw.binom.agentik.skills.renderSystemPromptSection
|
import pw.binom.agentik.skills.renderSystemPromptSection
|
||||||
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
import pw.binom.agentik.journal.ConversationStore
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
import pw.binom.agentik.journal.Ids
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
import pw.binom.agentik.reflection.Reflection
|
import pw.binom.agentik.reflection.Reflection
|
||||||
import pw.binom.agentik.reflection.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.agentik.journal.MutableJournalStore
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
@@ -65,7 +59,7 @@ import pw.binom.litert.LiteLlm
|
|||||||
*/
|
*/
|
||||||
class ChatAgent(
|
class ChatAgent(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
private val conversationStore: ConversationStore,
|
private val mutableConversationStore: MutableConversationStore,
|
||||||
private val messageStore: MutableJournalStore,
|
private val messageStore: MutableJournalStore,
|
||||||
private val workingMemoryStore: ContextStore,
|
private val workingMemoryStore: ContextStore,
|
||||||
private val reflectionStore: ReflectionStore,
|
private val reflectionStore: ReflectionStore,
|
||||||
@@ -239,6 +233,16 @@ class ChatAgent(
|
|||||||
override val outbox: OutboxStore
|
override val outbox: OutboxStore
|
||||||
get() = eventStore
|
get() = eventStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of [mutableConversationStore] для HTTP-фасада в `:server`
|
||||||
|
* (`GET /conversations` → список [ConversationRecord] для UI).
|
||||||
|
*
|
||||||
|
* Сужение с [MutableConversationStore] на [ConversationStore] тривиальна
|
||||||
|
* через interface-наследование (read-only projection).
|
||||||
|
*/
|
||||||
|
override val conversationStore: ConversationStore
|
||||||
|
get() = mutableConversationStore
|
||||||
|
|
||||||
/** Защищает карту живых диалогов. */
|
/** Защищает карту живых диалогов. */
|
||||||
private val liveLock = Mutex()
|
private val liveLock = Mutex()
|
||||||
private val live: MutableMap<String, ChatConversation> = HashMap()
|
private val live: MutableMap<String, ChatConversation> = HashMap()
|
||||||
@@ -262,12 +266,12 @@ class ChatAgent(
|
|||||||
// и не переживают рестарт агента (см. Memory #3709).
|
// и не переживают рестарт агента (см. Memory #3709).
|
||||||
if (!temp) {
|
if (!temp) {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
conversationStore.upsert(rec)
|
mutableConversationStore.upsert(rec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val conv = ChatConversation(
|
val conv = ChatConversation(
|
||||||
record = rec,
|
record = rec,
|
||||||
conversationStore = conversationStore,
|
conversationStore = mutableConversationStore,
|
||||||
messageStore = messageStore,
|
messageStore = messageStore,
|
||||||
workingMemoryStore = workingMemoryStore,
|
workingMemoryStore = workingMemoryStore,
|
||||||
reflectionStore = reflectionStore,
|
reflectionStore = reflectionStore,
|
||||||
@@ -302,7 +306,7 @@ class ChatAgent(
|
|||||||
|
|
||||||
override suspend fun getConversation(id: String): ProtoConversation? {
|
override suspend fun getConversation(id: String): ProtoConversation? {
|
||||||
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
|
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
|
||||||
val rec = conversationStore.get(id) ?: return null
|
val rec = mutableConversationStore.get(id) ?: return null
|
||||||
return newConversation(rec).also {
|
return newConversation(rec).also {
|
||||||
liveLock.withLock { live[id] = it }
|
liveLock.withLock { live[id] = it }
|
||||||
}
|
}
|
||||||
@@ -311,7 +315,7 @@ class ChatAgent(
|
|||||||
override suspend fun deleteConversation(id: String): Boolean {
|
override suspend fun deleteConversation(id: String): Boolean {
|
||||||
val conv = liveLock.withLock { live.remove(id) }
|
val conv = liveLock.withLock { live.remove(id) }
|
||||||
conv?.close()
|
conv?.close()
|
||||||
val ok = conversationStore.delete(id)
|
val ok = mutableConversationStore.delete(id)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
val event = AgentEvent.Deleted(date = now(), id = id)
|
val event = AgentEvent.Deleted(date = now(), id = id)
|
||||||
eventStore.append(CommonEvent.Agent(date = now(), event = event))
|
eventStore.append(CommonEvent.Agent(date = now(), event = event))
|
||||||
@@ -319,17 +323,16 @@ class ChatAgent(
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
|
override suspend fun renameConversation(id: String, title: String?): Instant? {
|
||||||
conversationStore.list(offset = offset, limit = limit).map { rec ->
|
val newUpdatedAt = mutableConversationStore.rename(id, title) ?: return null
|
||||||
liveLock.withLock { live[rec.id] }
|
val event = AgentEvent.Renamed(date = newUpdatedAt, id = id, title = title)
|
||||||
?: newConversation(rec).also {
|
eventStore.append(CommonEvent.Agent(date = newUpdatedAt, event = event))
|
||||||
liveLock.withLock { live[rec.id] = it }
|
return newUpdatedAt
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
|
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
|
||||||
record = rec,
|
record = rec,
|
||||||
conversationStore = conversationStore,
|
conversationStore = mutableConversationStore,
|
||||||
messageStore = messageStore,
|
messageStore = messageStore,
|
||||||
workingMemoryStore = workingMemoryStore,
|
workingMemoryStore = workingMemoryStore,
|
||||||
reflectionStore = reflectionStore,
|
reflectionStore = reflectionStore,
|
||||||
|
|||||||
+4
-4
@@ -3,15 +3,15 @@ package pw.binom.agentik.standalone.agent
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
|
||||||
import pw.binom.agentik.outbox.CommonEvent
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
import pw.binom.agentik.proto.Event as ProtoEvent
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||||
|
|
||||||
internal class ConversationEvents(
|
internal class ConversationEvents(
|
||||||
private val globalEventStore: MutableOutboxStore,
|
private val globalEventStore: MutableOutboxStore,
|
||||||
private val conversationId: String,
|
private val conversationId: String,
|
||||||
) {
|
) {
|
||||||
fun tryEmit(event: ProtoEvent): Boolean {
|
fun tryEmit(event: Event): Boolean {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
globalEventStore.append(
|
globalEventStore.append(
|
||||||
CommonEvent.Conversation(
|
CommonEvent.Conversation(
|
||||||
@@ -24,7 +24,7 @@ internal class ConversationEvents(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun events(after: kotlin.time.Instant?): Flow<ProtoEvent> =
|
fun events(after: kotlin.time.Instant?): Flow<Event> =
|
||||||
globalEventStore.conversationEvents(after = after, conversationId = conversationId)
|
globalEventStore.conversationEvents(after = after, conversationId = conversationId)
|
||||||
.map { it.event }
|
.map { it.event }
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-6
@@ -7,7 +7,6 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -31,7 +30,7 @@ import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
|
|||||||
import pw.binom.agentik.skills.SkillStore
|
import pw.binom.agentik.skills.SkillStore
|
||||||
import pw.binom.agentik.journal.Content
|
import pw.binom.agentik.journal.Content
|
||||||
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.agentik.journal.MessageContext
|
import pw.binom.agentik.journal.MessageContext
|
||||||
import pw.binom.agentik.journal.MessageOrigin
|
import pw.binom.agentik.journal.MessageOrigin
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
@@ -54,7 +53,7 @@ import pw.binom.agentik.toolsets.NamedTool
|
|||||||
|
|
||||||
class ConversationLoop(
|
class ConversationLoop(
|
||||||
record: ConversationRecord,
|
record: ConversationRecord,
|
||||||
private val conversationStore: ConversationStore,
|
private val conversationStore: MutableConversationStore,
|
||||||
private val messageStore: MutableJournalStore,
|
private val messageStore: MutableJournalStore,
|
||||||
private val workingMemoryStore: ContextStore,
|
private val workingMemoryStore: ContextStore,
|
||||||
private val reflectionStore: ReflectionStore?,
|
private val reflectionStore: ReflectionStore?,
|
||||||
@@ -220,9 +219,6 @@ class ConversationLoop(
|
|||||||
toolDispatcher.currentToolJob?.cancel()
|
toolDispatcher.currentToolJob?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<ProtoEvent> =
|
|
||||||
events.events(after)
|
|
||||||
|
|
||||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<ProtoMessage> =
|
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<ProtoMessage> =
|
||||||
messageStore.list(conversationId = id, after = after, offset = offset, limit = limit)
|
messageStore.list(conversationId = id, after = after, offset = offset, limit = limit)
|
||||||
.map { it.toProto() }
|
.map { it.toProto() }
|
||||||
@@ -443,6 +439,18 @@ class ConversationLoop(
|
|||||||
|
|
||||||
state.record = state.record.copy(updatedAt = assistantAt)
|
state.record = state.record.copy(updatedAt = assistantAt)
|
||||||
conversationStore.touch(id, assistantAt)
|
conversationStore.touch(id, assistantAt)
|
||||||
|
if (!state.isTemporal) {
|
||||||
|
eventStore.append(
|
||||||
|
pw.binom.agentik.outbox.CommonEvent.Agent(
|
||||||
|
date = assistantAt,
|
||||||
|
event = pw.binom.agentik.outbox.AgentEvent.Touched(
|
||||||
|
date = assistantAt,
|
||||||
|
id = id,
|
||||||
|
updatedAt = assistantAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus
|
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus
|
||||||
// (compaction/lifecycle/tool-failure events). Никаких interval-based
|
// (compaction/lifecycle/tool-failure events). Никаких interval-based
|
||||||
@@ -568,6 +576,7 @@ internal fun MessageRecord.toProto(): ProtoMessage = when (this) {
|
|||||||
is MessageRecord.ToolResult -> ProtoMessage.ToolResult(
|
is MessageRecord.ToolResult -> ProtoMessage.ToolResult(
|
||||||
id = id,
|
id = id,
|
||||||
date = createdAt,
|
date = createdAt,
|
||||||
|
toolName = toolName,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
is MessageRecord.Error -> ProtoMessage.Error(
|
is MessageRecord.Error -> ProtoMessage.Error(
|
||||||
|
|||||||
+2
-1
@@ -93,7 +93,7 @@ internal class ToolDispatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val resultAt = now()
|
val resultAt = now()
|
||||||
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, id = resultId, result = resultText))
|
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, toolCallId = callId, toolName = call.name, result = resultText))
|
||||||
|
|
||||||
// Эмитим background event — другие компоненты (BackgroundScheduler)
|
// Эмитим background event — другие компоненты (BackgroundScheduler)
|
||||||
// решают, делать ли что-то. Cancellation = not a failure (не эмитим Failed).
|
// решают, делать ли что-то. Cancellation = not a failure (не эмитим Failed).
|
||||||
@@ -110,6 +110,7 @@ internal class ToolDispatcher(
|
|||||||
id = resultId,
|
id = resultId,
|
||||||
conversationId = state.id,
|
conversationId = state.id,
|
||||||
toolCallId = callId,
|
toolCallId = callId,
|
||||||
|
toolName = call.name,
|
||||||
result = resultText,
|
result = resultText,
|
||||||
createdAt = resultAt,
|
createdAt = resultAt,
|
||||||
),
|
),
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
|
import pw.binom.agentik.reflection.Reflection
|
||||||
|
|
||||||
|
class ChatAgentReflectionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildSystemPrompt omits section when reflections empty`() {
|
||||||
|
val prompt = buildSystemPrompt(
|
||||||
|
base = "base",
|
||||||
|
skills = SkillCatalog.EMPTY,
|
||||||
|
memoryEnabled = false,
|
||||||
|
soulBody = null,
|
||||||
|
reflections = emptyList(),
|
||||||
|
)
|
||||||
|
assertFalse(prompt.contains("Self-reflection"))
|
||||||
|
assertFalse(prompt.contains("слабые места"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildSystemPrompt includes section with weak spots when reflections non-empty`() {
|
||||||
|
val r = Reflection(
|
||||||
|
id = "r1",
|
||||||
|
conversationId = null,
|
||||||
|
createdAt = Instant.parse("2026-09-15T12:00:00Z"),
|
||||||
|
turnsAnalyzed = 10,
|
||||||
|
score = 2,
|
||||||
|
summary = "плохо",
|
||||||
|
weakSpots = listOf("медленно отвечаю на X", "путаю A и B"),
|
||||||
|
)
|
||||||
|
val prompt = buildSystemPrompt(
|
||||||
|
base = "base",
|
||||||
|
skills = SkillCatalog.EMPTY,
|
||||||
|
memoryEnabled = false,
|
||||||
|
soulBody = null,
|
||||||
|
reflections = listOf(r),
|
||||||
|
)
|
||||||
|
assertTrue(prompt.contains("Self-reflection"))
|
||||||
|
assertTrue(prompt.contains("слабые места"))
|
||||||
|
assertTrue(prompt.contains("медленно отвечаю на X"))
|
||||||
|
assertTrue(prompt.contains("путаю A и B"))
|
||||||
|
assertTrue(prompt.contains("score=2/5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildSystemPrompt places reflection section after memory and before soul prepend`() {
|
||||||
|
val r = Reflection(
|
||||||
|
id = "r1",
|
||||||
|
conversationId = null,
|
||||||
|
createdAt = Instant.parse("2026-09-15T12:00:00Z"),
|
||||||
|
turnsAnalyzed = 5,
|
||||||
|
score = 4,
|
||||||
|
summary = "ok",
|
||||||
|
weakSpots = listOf("minor issue"),
|
||||||
|
)
|
||||||
|
val prompt = buildSystemPrompt(
|
||||||
|
base = "base",
|
||||||
|
skills = SkillCatalog.EMPTY,
|
||||||
|
memoryEnabled = true,
|
||||||
|
soulBody = "I am a soul",
|
||||||
|
reflections = listOf(r),
|
||||||
|
)
|
||||||
|
// soul первый, reflection последняя
|
||||||
|
val soulIdx = prompt.indexOf("I am a soul")
|
||||||
|
val reflIdx = prompt.indexOf("Self-reflection")
|
||||||
|
assertTrue(soulIdx >= 0 && reflIdx >= 0)
|
||||||
|
assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||||
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
|
import pw.binom.agentik.skills.SkillFile
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
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.LiteTool
|
||||||
|
import pw.binom.litert.LiteToolCall
|
||||||
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import pw.binom.agentik.toolsets.NamedTool
|
||||||
|
|
||||||
|
class ChatAgentTest {
|
||||||
|
|
||||||
|
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
private lateinit var fakeLlm: FakeLiteLlm
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
|
||||||
|
fakeLlm = FakeLiteLlm()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
sqliteStores.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newAgent(
|
||||||
|
sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores,
|
||||||
|
llm: LiteLlm = this.fakeLlm,
|
||||||
|
tools: List<NamedTool> = emptyList(),
|
||||||
|
skills: SkillCatalog = SkillCatalog.EMPTY,
|
||||||
|
): ChatAgent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = llm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
tools = tools,
|
||||||
|
skills = skills,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
|
||||||
|
// System prompt живёт ТОЛЬКО in-memory в ChatConversation.systemPrompt
|
||||||
|
// и едет в LLM через LiteConversationConfig.systemInstruction. В
|
||||||
|
// working_memory ничего не пишется — старт system prompt чистый,
|
||||||
|
// 0 entries.
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
assertEquals(0, wm.size)
|
||||||
|
// System prompt виден через LiteConversationConfig, который LLM получит
|
||||||
|
// при первом send (см. `send passes system prompt and past history to LLM on first send`).
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skills are appended to the system prompt passed to LLM`() = runTest {
|
||||||
|
// Skills добавляются в system prompt на лету при сборке ChatAgent.
|
||||||
|
// Проверяем это через то, что увидит LLM — systemInstruction в
|
||||||
|
// LiteConversationConfig (а не через working_memory, куда теперь
|
||||||
|
// ничего про system prompt не пишется).
|
||||||
|
val skills = SkillCatalog(
|
||||||
|
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
||||||
|
)
|
||||||
|
val agent = newAgent(skills = skills)
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
|
||||||
|
val system = fakeLlm.lastConfig!!.systemInstruction
|
||||||
|
assertNotNull(system)
|
||||||
|
assertTrue("be brief" in system!!, "base prompt missing: $system")
|
||||||
|
assertTrue("## Навыки" in system, "skills section missing: $system")
|
||||||
|
assertTrue("lint" in system, "skill name missing: $system")
|
||||||
|
assertTrue("lint things" in system, "skill description missing: $system")
|
||||||
|
assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `read_skill tool is registered when skills present`() = runTest {
|
||||||
|
val skills = SkillCatalog(
|
||||||
|
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
||||||
|
)
|
||||||
|
val agent = newAgent(skills = skills)
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
|
||||||
|
val descriptors = fakeLlm.lastConfig!!.tools.map { it.describe() }
|
||||||
|
assertTrue(descriptors.any { SkillReadTool.NAME in it }, "expected read_skill tool: $descriptors")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no read_skill tool when skills absent`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
|
||||||
|
val descriptors = fakeLlm.lastConfig?.tools?.map { it.describe() } ?: emptyList()
|
||||||
|
assertTrue(descriptors.none { SkillReadTool.NAME in it }, "unexpected read_skill tool: $descriptors")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getConversation returns null for unknown id`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
assertNull(agent.getConversation("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `conversationStore list returns all stored persistent conversations`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
agent.createConversation(temp = false)
|
||||||
|
agent.createConversation(temp = true)
|
||||||
|
val list = agent.conversationStore.list(0, 10)
|
||||||
|
// temp-беседы не персистятся, в списке только persistent
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `renameConversation updates title and emits event`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
val newTitle = "Renamed!"
|
||||||
|
val updatedAt = agent.renameConversation(conv.id, newTitle)
|
||||||
|
assertNotNull(updatedAt)
|
||||||
|
val rec = agent.conversationStore.get(conv.id)
|
||||||
|
assertEquals(newTitle, rec?.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `renameConversation returns null for unknown id`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
assertNull(agent.renameConversation("nope", "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteConversation removes conversation and data`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
val id = conv.id
|
||||||
|
|
||||||
|
// добавим сообщение, чтобы потом убедиться, что каскад сработал
|
||||||
|
sqliteStores.messages.append(
|
||||||
|
pw.binom.agentik.journal.MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = id,
|
||||||
|
content = listOf(pw.binom.agentik.journal.Content.Text("hi")),
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(agent.deleteConversation(id))
|
||||||
|
assertNull(agent.getConversation(id))
|
||||||
|
assertNull(sqliteStores.conversations.get(id))
|
||||||
|
assertEquals(emptyList(), sqliteStores.messages.listFlow(id, Instant.DISTANT_PAST).toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteConversation returns false for unknown id`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
assertEquals(false, agent.deleteConversation("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send emits start_reasoning, start_response, append_text, end`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.reply = "hello back"
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
|
||||||
|
// user message записан в audit + working memory
|
||||||
|
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(2, msgs.size)
|
||||||
|
assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let {
|
||||||
|
(it[0] as pw.binom.agentik.journal.Content.Text).body
|
||||||
|
})
|
||||||
|
assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let {
|
||||||
|
(it[0] as pw.binom.agentik.journal.Content.Text).body
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send reconstructs conversation history from working memory`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
fakeLlm.reply = "first reply"
|
||||||
|
val conv1 = agent.createConversation(temp = false)
|
||||||
|
conv1.send(listOf(Content.Text("first user")))
|
||||||
|
|
||||||
|
// Новая беседа не должна видеть историю первой
|
||||||
|
val conv2 = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "second reply"
|
||||||
|
conv2.send(listOf(Content.Text("second user")))
|
||||||
|
|
||||||
|
// В working_memory теперь НЕТ System-entries — только user + assistant.
|
||||||
|
// Системный промт живёт в ChatConversation.systemPrompt и едет в LLM
|
||||||
|
// через LiteConversationConfig.systemInstruction.
|
||||||
|
val wm1 = sqliteStores.workingMemory.list(conv1.id)
|
||||||
|
assertEquals(2, wm1.size)
|
||||||
|
val wm2 = sqliteStores.workingMemory.list(conv2.id)
|
||||||
|
assertEquals(2, wm2.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send passes system prompt and past history to LLM on first send`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "hi"
|
||||||
|
conv.send(listOf(Content.Text("hello")))
|
||||||
|
|
||||||
|
// Длинно-живущий LiteConversation: первый send создаёт его с systemInstruction
|
||||||
|
// и пустыми initialMessages (свежее user-сообщение пойдёт через sendStreamContents).
|
||||||
|
assertNotNull(fakeLlm.lastConfig)
|
||||||
|
assertEquals("be brief", fakeLlm.lastConfig!!.systemInstruction)
|
||||||
|
assertEquals(0, fakeLlm.lastConfig!!.initialMessages.size)
|
||||||
|
// Свежее user-сообщение отправлено через sendStreamContents
|
||||||
|
assertEquals(1, fakeLlm.conversations.size)
|
||||||
|
val sent = fakeLlm.lastContents
|
||||||
|
assertNotNull(sent)
|
||||||
|
assertEquals(1, sent.size)
|
||||||
|
assertEquals("hello", (sent[0] as LiteContentPart.Text).text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-turn conversation accumulates history but recreates LiteConv each turn`() = runTest {
|
||||||
|
// Новая семантика (radical close+recreate после commit 7):
|
||||||
|
// каждый turn закрывает LiteConv и на следующем send() создаёт новую
|
||||||
|
// через getOrCreateLiteConversation, которая пересобирает initialMessages
|
||||||
|
// из working memory. То есть LiteConv — один на turn, не на диалог.
|
||||||
|
// Преимущество: interrupt можно сделать тривиально (close + cancelProcess),
|
||||||
|
// KV-cache жертвуем ради предсказуемости (~2s prefill на Gemma-4-E2B).
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
fakeLlm.reply = "first reply"
|
||||||
|
conv.send(listOf(Content.Text("first user")))
|
||||||
|
// первый turn: WM = [user, assistant]
|
||||||
|
assertEquals(2, sqliteStores.workingMemory.list(conv.id).size)
|
||||||
|
|
||||||
|
fakeLlm.reply = "second reply"
|
||||||
|
conv.send(listOf(Content.Text("second user")))
|
||||||
|
// второй turn: WM должен вырасти до [user, assistant, user, assistant]
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
System.err.println("[TEST] wm.size=${wm.size}")
|
||||||
|
wm.forEachIndexed { i, row -> System.err.println("[TEST] $i: ${row.entry::class.simpleName} id=${row.id}") }
|
||||||
|
assertEquals(4, wm.size)
|
||||||
|
// Новая семантика: один LiteConv на turn → два LiteConv после двух send'ов.
|
||||||
|
assertEquals(2, fakeLlm.conversations.size)
|
||||||
|
// Второй LiteConv создан с initialMessages из working memory, ИСКЛЮЧАЯ pending user2
|
||||||
|
// (он передаётся в sendStreamContents, чтобы не дублироваться).
|
||||||
|
val reopened = fakeLlm.conversations.last()
|
||||||
|
assertEquals(2, reopened.initialMessages.size)
|
||||||
|
assertEquals("first user", reopened.initialMessages[0].text)
|
||||||
|
assertEquals(LiteRole.USER, reopened.initialMessages[0].role)
|
||||||
|
assertEquals("first reply", reopened.initialMessages[1].text)
|
||||||
|
assertEquals(LiteRole.MODEL, reopened.initialMessages[1].role)
|
||||||
|
// После sendStreamContents (с user2 + сгенерированный asst2) mutableHistory = 4
|
||||||
|
assertEquals(4, reopened.history.size)
|
||||||
|
assertEquals("second reply", reopened.history.last().text)
|
||||||
|
assertEquals(LiteRole.MODEL, reopened.history.last().role)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reloaded conversation reconstructs LiteConversation from working memory`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.rememberHistory = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "first reply"
|
||||||
|
conv.send(listOf(Content.Text("first user")))
|
||||||
|
val convId = conv.id
|
||||||
|
conv.close()
|
||||||
|
|
||||||
|
// Открываем новое ChatConversation с тем же id — LiteConversation должен
|
||||||
|
// быть создан заново из working memory (первый user+assistant как initial).
|
||||||
|
val reopened = agent.getConversation(convId)!!
|
||||||
|
fakeLlm.reply = "second reply"
|
||||||
|
reopened.send(listOf(Content.Text("second user")))
|
||||||
|
|
||||||
|
val allConvs = fakeLlm.conversations
|
||||||
|
assertEquals(2, allConvs.size) // original + reopened
|
||||||
|
val reopenedLite = allConvs.last()
|
||||||
|
// Initial messages: только прошлые user+assistant (НЕ включая текущий "second user")
|
||||||
|
assertEquals(2, reopenedLite.initialMessages.size)
|
||||||
|
assertEquals("first user", reopenedLite.initialMessages[0].text)
|
||||||
|
assertEquals(LiteRole.USER, reopenedLite.initialMessages[0].role)
|
||||||
|
assertEquals("first reply", reopenedLite.initialMessages[1].text)
|
||||||
|
assertEquals(LiteRole.MODEL, reopenedLite.initialMessages[1].role)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LLM failure emits error event and persists error message`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.failMessage = "boom from llm"
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
|
val events = mutableListOf<ProtoEvent>()
|
||||||
|
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||||
|
}
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
delay(50)
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events")
|
||||||
|
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
||||||
|
|
||||||
|
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(2, msgs.size)
|
||||||
|
assertIs<pw.binom.agentik.journal.MessageRecord.UserMessage>(msgs[0])
|
||||||
|
val err = assertIs<pw.binom.agentik.journal.MessageRecord.Error>(msgs[1])
|
||||||
|
assertEquals("boom from llm", err.message)
|
||||||
|
|
||||||
|
// backfill через getMessages (polling/reconnect) тоже видит ошибку
|
||||||
|
val proto = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = 10)
|
||||||
|
assertTrue(
|
||||||
|
proto.any { it is pw.binom.agentik.proto.Message.Error && it.message == "boom from llm" },
|
||||||
|
"history=$proto",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest {
|
||||||
|
// Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel()
|
||||||
|
// бросает CancellationException в стриме, runTurn выходит через finally.
|
||||||
|
// Если turn не успел ничего сгенерить (reply.isEmpty() && toolExchanges.isEmpty())
|
||||||
|
// — AssistantMessage в audit log НЕ пишется. Только user + End/Interrupted.
|
||||||
|
val agent = newAgent()
|
||||||
|
fakeLlm.slow = true
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
// Подписываемся на events ДО send() — SharedFlow без replay, после
|
||||||
|
// отправки событий подписка ничего не увидит.
|
||||||
|
val events = mutableListOf<ProtoEvent>()
|
||||||
|
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val sendJob = launch {
|
||||||
|
try {
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
|
// ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ждём, пока корутина дойдёт до sendStreamContents и повиснет на slow-эмиссии
|
||||||
|
delay(200)
|
||||||
|
conv.interrupt()
|
||||||
|
sendJob.join()
|
||||||
|
eventsJob.cancel()
|
||||||
|
|
||||||
|
// audit: только user (assistant не успел сгенериться)
|
||||||
|
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(1, msgs.size)
|
||||||
|
assertIs<pw.binom.agentik.journal.MessageRecord.UserMessage>(msgs[0])
|
||||||
|
|
||||||
|
// working memory: только user (assistant skipped because пустой)
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
assertEquals(1, wm.size)
|
||||||
|
assertTrue(wm[0].entry is WorkingMemoryEntry.User)
|
||||||
|
|
||||||
|
// events: должны включать Interrupted + End
|
||||||
|
assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events")
|
||||||
|
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interrupt after tool execution preserves tool result in working memory`() = runTest {
|
||||||
|
// Сценарий "LLM вызвал тул, инструмент выполнился, потом interrupt()":
|
||||||
|
// 1. LLM скриптован на ToolCalls([echo_tool])
|
||||||
|
// 2. Tool реально вызывается через toolsetDispatch.dispatch()
|
||||||
|
// 3. interrupt() приходит в окне между финальным text и завершением turn'а
|
||||||
|
//
|
||||||
|
// В audit log: user + ToolCall + ToolResult (инструмент выполнился).
|
||||||
|
// В working_memory: user + ToolExchange(result=echo output, wasCancelled=false).
|
||||||
|
// В events: ToolCall + ToolResult + Interrupted + End.
|
||||||
|
val agent = newAgent()
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
// LLM скриптован: tool call.
|
||||||
|
fakeLlm.scriptedReplies = mutableListOf(
|
||||||
|
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
val echoTool = object : LiteTool {
|
||||||
|
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
|
||||||
|
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
|
||||||
|
}
|
||||||
|
agent.registerToolForTest("echo_tool", echoTool)
|
||||||
|
|
||||||
|
// Подписываемся ДО send — SharedFlow без replay
|
||||||
|
val events = mutableListOf<ProtoEvent>()
|
||||||
|
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id).collect { events.add(it.event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val sendJob = launch {
|
||||||
|
try {
|
||||||
|
conv.send(listOf(Content.Text("run echo tool")))
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {}
|
||||||
|
}
|
||||||
|
// Ждём пока инструмент выполнится (turn завершится нормально)
|
||||||
|
sendJob.join()
|
||||||
|
// interrupt() ПОСЛЕ завершения turn — не должно ничего менять в БД,
|
||||||
|
// но проверяем что events включает все ожидаемые типы.
|
||||||
|
conv.interrupt()
|
||||||
|
eventsJob.cancel()
|
||||||
|
|
||||||
|
// audit: user + toolcall + toolresult (tool выполнился), assistant может быть
|
||||||
|
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
||||||
|
val toolResult = msgs.filterIsInstance<pw.binom.agentik.journal.MessageRecord.ToolResult>().firstOrNull()
|
||||||
|
assertNotNull(toolResult, "tool result должен быть в audit — tool выполнился нормально")
|
||||||
|
val toolResultResult = toolResult!!.result!!
|
||||||
|
assertTrue(toolResultResult.contains("echo"), "tool result содержит реальный ответ тулы: $toolResultResult")
|
||||||
|
|
||||||
|
// working memory: user + tool_exchange
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
val exchanges = wm.mapNotNull { (it.entry as? WorkingMemoryEntry.ToolExchange) }
|
||||||
|
assertEquals(1, exchanges.size)
|
||||||
|
assertEquals("echo_tool", exchanges[0].toolName)
|
||||||
|
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
|
||||||
|
assertTrue(exchanges[0].resultText.contains("echo"))
|
||||||
|
|
||||||
|
// events должны включать ToolCall + ToolResult. End — обязательно (turn завершился).
|
||||||
|
assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events")
|
||||||
|
assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events")
|
||||||
|
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `temp conversation is not persisted across agent instances`() = runTest {
|
||||||
|
// Поднимаем file-backed БД, создаём temp-беседу
|
||||||
|
sqliteStores.close()
|
||||||
|
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
||||||
|
sqliteStores = KsqliteStores.open(dbPath)
|
||||||
|
val agent1 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = FakeLiteLlm().also { fakeLlm = it },
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val tempConv = agent1.createConversation(temp = true)
|
||||||
|
val tempId = tempConv.id
|
||||||
|
assertNotNull(agent1.getConversation(tempId))
|
||||||
|
|
||||||
|
// Переоткрываем БД — temp-беседа не должна пережить рестарт
|
||||||
|
sqliteStores.close()
|
||||||
|
sqliteStores = KsqliteStores.open(dbPath)
|
||||||
|
val agent2 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNull(agent2.getConversation(tempId))
|
||||||
|
java.io.File(dbPath).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-temp conversation persists across agent instances`() = runTest {
|
||||||
|
sqliteStores.close()
|
||||||
|
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
||||||
|
sqliteStores = KsqliteStores.open(dbPath)
|
||||||
|
val agent1 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = FakeLiteLlm().also { fakeLlm = it },
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val conv = agent1.createConversation(temp = false)
|
||||||
|
val id = conv.id
|
||||||
|
|
||||||
|
sqliteStores.close()
|
||||||
|
sqliteStores = KsqliteStores.open(dbPath)
|
||||||
|
val agent2 = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNotNull(agent2.getConversation(id))
|
||||||
|
java.io.File(dbPath).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm {
|
||||||
|
override val backendName: String = "fake"
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
override fun createConversation(config: LiteConversationConfig): LiteConversation =
|
||||||
|
error("not used in reload test")
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used")
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> = error("not used")
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tool-call loop executes registered tool and feeds result back`() = runTest {
|
||||||
|
val echoTool = object : LiteTool {
|
||||||
|
override fun describe(): String = """{"type":"function","function":{"name":"echo"}}"""
|
||||||
|
override fun invoke(arguments: String): String = "echoed: $arguments"
|
||||||
|
}
|
||||||
|
val toolLlm = ToolLoopFakeLiteLlm()
|
||||||
|
val agent = newAgent(llm = toolLlm, tools = listOf(NamedTool("echo", echoTool)))
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
|
||||||
|
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
||||||
|
|
||||||
|
// sendStreamContents вызывается дважды: первый раз с user-сообщением
|
||||||
|
// (LLM отвечает tool_call), второй раз — после addToolResult — для
|
||||||
|
// триггера continuation у stateless-бэкендов (OpenAI). На этой fake
|
||||||
|
// LiteLlm оба попадают в счётчик.
|
||||||
|
assertEquals(2, toolLlm.toolCallCount, "expected user send + post-tool continuation")
|
||||||
|
assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult,
|
||||||
|
"expected echo tool invoked with the LLM's args, result fed back via addToolResult")
|
||||||
|
assertEquals("final reply", toolLlm.finalReplyEmitted,
|
||||||
|
"expected continuation send after tool result to emit final text")
|
||||||
|
|
||||||
|
agent.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `agentEvents - Created + Deleted flow`() = runTest {
|
||||||
|
val agent = newAgent()
|
||||||
|
val events = mutableListOf<AgentEvent>()
|
||||||
|
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||||
|
// Agent.events() удалён из :proto — события живут в
|
||||||
|
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
|
||||||
|
// распаковываем .event для получения AgentEvent.
|
||||||
|
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { events.add(it.event) }
|
||||||
|
}
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
agent.deleteConversation(conv.id)
|
||||||
|
delay(50)
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
assertEquals(2, events.size)
|
||||||
|
val created = events[0] as AgentEvent.Created
|
||||||
|
val deleted = events[1] as AgentEvent.Deleted
|
||||||
|
assertEquals(conv.id, created.conversationId)
|
||||||
|
assertEquals(conv.id, deleted.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */
|
||||||
|
private class ToolLoopFakeLiteLlm : LiteLlm {
|
||||||
|
override val backendName: String = "fake-tool"
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
||||||
|
|
||||||
|
var toolCallCount: Int = 0
|
||||||
|
var lastToolResult: String? = null
|
||||||
|
var finalReplyEmitted: String? = null
|
||||||
|
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
|
||||||
|
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
||||||
|
return object : LiteConversation {
|
||||||
|
private val hist = mutableListOf<LiteMessage>()
|
||||||
|
override val history: List<LiteMessage> get() = hist.toList()
|
||||||
|
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||||
|
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||||
|
hist.add(LiteMessage(LiteRole.USER, contents))
|
||||||
|
toolCallCount++
|
||||||
|
return flowOf(
|
||||||
|
LiteDelta(
|
||||||
|
text = "",
|
||||||
|
isDone = true,
|
||||||
|
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun send(prompt: String): String = "unused"
|
||||||
|
override fun sendContents(contents: List<LiteContentPart>): String = "unused"
|
||||||
|
override fun cancel() {}
|
||||||
|
override fun tokenCount(): Int = hist.size
|
||||||
|
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta {
|
||||||
|
lastToolResult = result
|
||||||
|
val reply = "final reply"
|
||||||
|
finalReplyEmitted = reply
|
||||||
|
return LiteDelta(text = reply, isDone = true)
|
||||||
|
}
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used")
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> = error("not used")
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
import pw.binom.agentik.toolsets.ToolsetContribution
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграционные тесты ChatAgent + toolsets: проверяем что при пустом
|
||||||
|
* toolsets=List (дефолт) агент ведёт себя как раньше (нет enable/disable тулов,
|
||||||
|
* нет секции в system prompt), а при non-empty — добавляет их и регистрирует
|
||||||
|
* диспетчер.
|
||||||
|
*/
|
||||||
|
class ChatAgentToolsetsTest {
|
||||||
|
|
||||||
|
private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
||||||
|
override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}"""
|
||||||
|
override fun invoke(arguments: String) = response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stubLlm(): LiteLlm = object : LiteLlm {
|
||||||
|
override val backendName: String = "stub"
|
||||||
|
override fun isInitialized(): Boolean = true
|
||||||
|
override fun createConversation(config: pw.binom.litert.LiteConversationConfig): pw.binom.litert.LiteConversation =
|
||||||
|
throw UnsupportedOperationException("not used in this test")
|
||||||
|
override fun infer(request: pw.binom.litert.LiteRequest): String =
|
||||||
|
throw UnsupportedOperationException("not used in this test")
|
||||||
|
override fun inferStream(request: pw.binom.litert.LiteRequest): kotlinx.coroutines.flow.Flow<pw.binom.litert.LiteDelta> =
|
||||||
|
throw UnsupportedOperationException("not used in this test")
|
||||||
|
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
||||||
|
override fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newAgent(
|
||||||
|
toolsets: List<ToolsetContribution> = emptyList(),
|
||||||
|
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> {
|
||||||
|
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
|
||||||
|
val agent = ChatAgent(
|
||||||
|
id = "test-agent",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = stubLlm(),
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = pw.binom.agentik.standalone.llm.LlmBackend.GOOGLE,
|
||||||
|
systemPrompt = "base",
|
||||||
|
google = pw.binom.agentik.standalone.llm.GoogleConfig(modelPath = "/tmp/fake.gguf"),
|
||||||
|
),
|
||||||
|
toolsets = toolsets,
|
||||||
|
)
|
||||||
|
return agent to sqliteStores
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default (empty toolsets) does not register enable or disable tools`() {
|
||||||
|
val (agent, storage) = newAgent()
|
||||||
|
try {
|
||||||
|
// Проверяем через allTools-эквивалент: вызываем enable_toolset
|
||||||
|
// и ожидаем что он не найден — это значит тул не зарегистрирован.
|
||||||
|
val conv = agent.createConversation(temp = true)
|
||||||
|
// system prompt не должен содержать секции Toolsets
|
||||||
|
val prompt = (conv as ChatConversation).let { it.systemPromptForTest() }
|
||||||
|
assertFalse(prompt.contains("## Toolsets"), "toolsets section must NOT appear when toolsets empty")
|
||||||
|
assertFalse(prompt.contains("enable_toolset"), "enable_toolset must NOT be mentioned when toolsets empty")
|
||||||
|
} finally { storage.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-empty toolsets registers enable and disable tools and includes section`() {
|
||||||
|
val toolsets = listOf(
|
||||||
|
ToolsetContribution(
|
||||||
|
name = "media",
|
||||||
|
description = "image and video processing",
|
||||||
|
tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))),
|
||||||
|
),
|
||||||
|
ToolsetContribution(
|
||||||
|
name = "web",
|
||||||
|
description = "fetch and parse web pages",
|
||||||
|
tools = listOf(ToolsetContribution.ToolEntry("fetch_url", stubTool("fetch_url"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val (agent, storage) = newAgent(toolsets = toolsets)
|
||||||
|
try {
|
||||||
|
val conv = agent.createConversation(temp = true) as ChatConversation
|
||||||
|
val prompt = conv.systemPromptForTest()
|
||||||
|
assertTrue(prompt.contains("## Toolsets"), "toolsets section MUST appear when toolsets non-empty")
|
||||||
|
assertTrue(prompt.contains("- media — image and video processing"))
|
||||||
|
assertTrue(prompt.contains("- web — fetch and parse web pages"))
|
||||||
|
// Оба тула — в списке allTools (через system prompt проверяем только prompt;
|
||||||
|
// наличие тулов проверим отдельно — см. ниже).
|
||||||
|
} finally { storage.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-empty toolsets - enable_toolset activates and dispatches auto-included tools`() {
|
||||||
|
val mediaTool = stubTool("resize_image", "image-resized-100x100")
|
||||||
|
val toolsets = listOf(
|
||||||
|
ToolsetContribution(
|
||||||
|
name = "media",
|
||||||
|
description = "image and video processing",
|
||||||
|
tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val (agent, storage) = newAgent(toolsets = toolsets)
|
||||||
|
try {
|
||||||
|
val conv = agent.createConversation(temp = true) as ChatConversation
|
||||||
|
|
||||||
|
// Вызываем enable_toolset через диспетчер (минуя LLM, напрямую)
|
||||||
|
val enabled = runBlocking {
|
||||||
|
conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""")
|
||||||
|
}
|
||||||
|
assertEquals("Toolset 'media' activated.", enabled)
|
||||||
|
|
||||||
|
// Теперь resize_image должен работать (тулсет активен)
|
||||||
|
val resized = runBlocking {
|
||||||
|
conv.dispatchToolForTest("resize_image", "{}")
|
||||||
|
}
|
||||||
|
assertEquals("image-resized-100x100", resized)
|
||||||
|
} finally { storage.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-empty toolsets - calling tool from inactive toolset triggers auto-activation`() {
|
||||||
|
val mediaTool = stubTool("resize_image", "auto-activated-and-ran")
|
||||||
|
val toolsets = listOf(
|
||||||
|
ToolsetContribution(
|
||||||
|
name = "media",
|
||||||
|
description = "x",
|
||||||
|
tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val (agent, storage) = newAgent(toolsets = toolsets)
|
||||||
|
try {
|
||||||
|
val conv = agent.createConversation(temp = true) as ChatConversation
|
||||||
|
|
||||||
|
// Без enable сразу вызываем resize_image — диспетчер должен auto-activate.
|
||||||
|
val result = runBlocking {
|
||||||
|
conv.dispatchToolForTest("resize_image", "{}")
|
||||||
|
}
|
||||||
|
assertEquals("auto-activated-and-ran", result)
|
||||||
|
} finally { storage.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-empty toolsets - disable_toolset removes from active list`() {
|
||||||
|
// Тест проверяет только сайд-эффект на реестре (active set),
|
||||||
|
// а не реальный dispatch — потому что диспетчер auto-activate'ит тулсет
|
||||||
|
// обратно при следующем вызове (это by design: если модель забыла что
|
||||||
|
// тулсет выключен, мы прощаем и включаем заново).
|
||||||
|
val toolsets = listOf(
|
||||||
|
ToolsetContribution(
|
||||||
|
name = "media",
|
||||||
|
description = "x",
|
||||||
|
tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val (agent, storage) = newAgent(toolsets = toolsets)
|
||||||
|
try {
|
||||||
|
val conv = agent.createConversation(temp = true) as ChatConversation
|
||||||
|
|
||||||
|
runBlocking { conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") }
|
||||||
|
// disable
|
||||||
|
val disabled = runBlocking {
|
||||||
|
conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""")
|
||||||
|
}
|
||||||
|
assertEquals("Toolset 'media' deactivated.", disabled)
|
||||||
|
// После disable реестр уже не содержит media в active
|
||||||
|
// (проверяем косвенно: disable повторно всё ещё возвращает тот же uniform message)
|
||||||
|
val disabledAgain = runBlocking {
|
||||||
|
conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""")
|
||||||
|
}
|
||||||
|
assertEquals("Toolset 'media' deactivated.", disabledAgain)
|
||||||
|
} finally { storage.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов.
|
||||||
|
internal fun ChatConversation.systemPromptForTest(): String {
|
||||||
|
// Через рефлексию достаём private val systemPrompt.
|
||||||
|
val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true }
|
||||||
|
return f.get(this) as String
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String {
|
||||||
|
// Через toolsetDispatch (если есть) или прямой toolsByName.
|
||||||
|
val dispatchField = this::class.java.declaredFields.first { it.name == "toolsetDispatch" }.apply { isAccessible = true }
|
||||||
|
val dispatch = dispatchField.get(this) ?: error("toolsetDispatch must be set when toolsets present")
|
||||||
|
val outcome = (dispatch as pw.binom.agentik.toolsets.ToolsetDispatchPolicy).dispatch(toolName, argsJson)
|
||||||
|
return when (outcome) {
|
||||||
|
is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Ran -> outcome.result
|
||||||
|
is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: $toolName]"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
import pw.binom.agentik.memory.md.KeywordMdReviewer
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
|
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
import pw.binom.agentik.proto.Content as ProtoContent
|
||||||
|
import pw.binom.litert.LiteConversation
|
||||||
|
import pw.binom.litert.LiteConversationConfig
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import pw.binom.litert.LiteMessage
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||||
|
import pw.binom.agentik.llm.tools.SummaryTurn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а
|
||||||
|
* при превышении порога, вызов суммаризатора, триггер memory review, и
|
||||||
|
* атомарный replace в working memory.
|
||||||
|
*/
|
||||||
|
class CompactionTest {
|
||||||
|
|
||||||
|
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
private lateinit var fakeLlm: FakeLiteLlm
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
|
||||||
|
fakeLlm = FakeLiteLlm()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
sqliteStores.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newAgent(
|
||||||
|
contextWindow: Int? = null,
|
||||||
|
compressionThreshold: Double = 0.8,
|
||||||
|
compactor: ContextCompactor? = null,
|
||||||
|
memoryStore: MemoryStore? = null,
|
||||||
|
): ChatAgent {
|
||||||
|
val reviewer = if (memoryStore != null) KeywordMdReviewer() else null
|
||||||
|
return ChatAgent(
|
||||||
|
id = "test",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
memoryStore = memoryStore,
|
||||||
|
memoryReviewer = reviewer,
|
||||||
|
contextWindow = contextWindow,
|
||||||
|
compressionThreshold = compressionThreshold,
|
||||||
|
contextCompactor = compactor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compaction is no-op when contextWindow is null`() = runTest {
|
||||||
|
// contextWindow=null → даже с огромной историей compaction не запустится.
|
||||||
|
fakeLlm.reply = "hi"
|
||||||
|
val agent = newAgent(contextWindow = null, compactor = RecordingCompactor("summary"))
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
repeat(10) {
|
||||||
|
conv.send(listOf(ProtoContent.Text("turn $it: ${"x".repeat(200)}")))
|
||||||
|
}
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
// Без compaction все ходы остаются в памяти (System + 10 user/assistant = 21 строк).
|
||||||
|
val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary }
|
||||||
|
assertEquals(0, summaries.size, "compaction must not run without contextWindow")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compaction is no-op when compactor is null but window is set`() = runTest {
|
||||||
|
fakeLlm.reply = "hi"
|
||||||
|
val agent = newAgent(contextWindow = 10, compactor = null)
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
conv.send(listOf(ProtoContent.Text("first")))
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
// System + User + Assistant = 3. Без compactor — никаких Summary.
|
||||||
|
val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary }
|
||||||
|
assertEquals(0, summaries.size, "no compaction runs without compactor")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compaction triggers when estimated tokens exceed threshold`() = runTest {
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
val compactor = RecordingCompactor("**Goal**: x\n**Active**: y\n**Resolved**: z")
|
||||||
|
// contextWindow = 20 chars → ~5 токенов. С порогом 0.5 (50%) — почти любой ход пробивает.
|
||||||
|
val agent = newAgent(contextWindow = 20, compressionThreshold = 0.5, compactor = compactor)
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
|
conv.send(listOf(ProtoContent.Text("user message one — long enough to cross threshold")))
|
||||||
|
|
||||||
|
// Compactor должен был быть вызван хотя бы раз.
|
||||||
|
assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold")
|
||||||
|
// В working memory должна появиться Summary.
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary }
|
||||||
|
assertTrue(summaries.isNotEmpty(), "at least one Summary entry should be present after compaction")
|
||||||
|
// Summary-текст — то, что вернул наш compactor.
|
||||||
|
val summaryText = (summaries.first().entry as pw.binom.agentik.context.WorkingMemoryEntry.Summary).text
|
||||||
|
assertTrue(summaryText.startsWith("**Goal**"), "summary text should come from compactor: $summaryText")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compaction calls memoryReviewer reviewPreCompaction`() = runTest {
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
val memStore = TestInMemoryMemoryStore()
|
||||||
|
val compactor = RecordingCompactor("compacted summary")
|
||||||
|
val agent = newAgent(
|
||||||
|
contextWindow = 30,
|
||||||
|
compressionThreshold = 0.5,
|
||||||
|
compactor = compactor,
|
||||||
|
memoryStore = memStore,
|
||||||
|
)
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
|
conv.send(listOf(ProtoContent.Text("Я обычно предпочитаю kotlin для бэкенда.")))
|
||||||
|
|
||||||
|
// Память должна получить хотя бы одну заметку от reviewPreCompaction.
|
||||||
|
val notes = memStore.list()
|
||||||
|
assertTrue(notes.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") },
|
||||||
|
"memory should capture a preference fact before compaction drops the turn")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compaction preserves recent turns (KEEP_RECENT_TURNS)`() = runTest {
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
val compactor = RecordingCompactor("compacted summary")
|
||||||
|
val agent = newAgent(contextWindow = 30, compressionThreshold = 0.3, compactor = compactor)
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
|
conv.send(listOf(ProtoContent.Text("first turn")))
|
||||||
|
conv.send(listOf(ProtoContent.Text("second turn")))
|
||||||
|
conv.send(listOf(ProtoContent.Text("third turn — long content ${"y".repeat(150)}")))
|
||||||
|
|
||||||
|
val wm = sqliteStores.workingMemory.list(conv.id)
|
||||||
|
// Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов.
|
||||||
|
// KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты.
|
||||||
|
val userAssistantCount = wm.count {
|
||||||
|
it.entry is pw.binom.agentik.context.WorkingMemoryEntry.User ||
|
||||||
|
it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Assistant
|
||||||
|
}
|
||||||
|
// Минимум 1 ход остаётся (KEEP_RECENT_TURNS).
|
||||||
|
assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingCompactor(private val result: String) : ContextCompactor {
|
||||||
|
var calls = 0
|
||||||
|
override suspend fun summarize(turns: List<SummaryTurn>): String {
|
||||||
|
calls++
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (InMemoryMemoryStore вынесен в [TestInMemoryMemoryStore].)
|
||||||
|
*/
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.journal.MessageContext
|
||||||
|
import pw.binom.agentik.journal.MessageOrigin.EVENT
|
||||||
|
import pw.binom.agentik.journal.MessageOrigin.SYSTEM
|
||||||
|
import pw.binom.agentik.journal.MessageOrigin.USER
|
||||||
|
import pw.binom.litert.LiteContentPart
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для префикса контекста инициации хода в user-сообщениях.
|
||||||
|
* Только для не-USER origin'ов. USER — без изменений.
|
||||||
|
*/
|
||||||
|
class ContextPrefixTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `USER origin produces no prefix`() {
|
||||||
|
val ctx = MessageContext(origin = USER)
|
||||||
|
val parts = listOf(LiteContentPart.Text("hello"))
|
||||||
|
val out = applyContextPrefix(parts, ctx)
|
||||||
|
assertEquals(parts, out, "USER should not modify content")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null context produces no prefix`() {
|
||||||
|
val parts = listOf(LiteContentPart.Text("hello"))
|
||||||
|
val out = applyContextPrefix(parts, null)
|
||||||
|
assertEquals(parts, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SYSTEM origin prepends label to first text part`() {
|
||||||
|
val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting")
|
||||||
|
val parts = listOf(LiteContentPart.Text("boot"))
|
||||||
|
val out = applyContextPrefix(parts, ctx)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
val text = assertIs<LiteContentPart.Text>(out[0])
|
||||||
|
assertEquals("[SYSTEM] agent startup greeting\nboot", text.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EVENT origin with sourceId includes it`() {
|
||||||
|
val ctx = MessageContext(
|
||||||
|
origin = EVENT,
|
||||||
|
description = "scheduled cron morning-briefing",
|
||||||
|
sourceId = "cron-42",
|
||||||
|
)
|
||||||
|
val parts = listOf(LiteContentPart.Text("wake up"))
|
||||||
|
val out = applyContextPrefix(parts, ctx)
|
||||||
|
val text = assertIs<LiteContentPart.Text>(out[0])
|
||||||
|
assertEquals("[EVENT] scheduled cron morning-briefing (sourceId=cron-42)\nwake up", text.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prefix only added to first text part, others untouched`() {
|
||||||
|
val ctx = MessageContext(origin = EVENT, description = "test")
|
||||||
|
val parts = listOf(
|
||||||
|
LiteContentPart.Text("first"),
|
||||||
|
LiteContentPart.Text("second"),
|
||||||
|
)
|
||||||
|
val out = applyContextPrefix(parts, ctx)
|
||||||
|
assertEquals(2, out.size)
|
||||||
|
val first = assertIs<LiteContentPart.Text>(out[0])
|
||||||
|
val second = assertIs<LiteContentPart.Text>(out[1])
|
||||||
|
assertEquals("[EVENT] test\nfirst", first.text)
|
||||||
|
assertEquals("second", second.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prefix with no text parts is prepended as standalone text`() {
|
||||||
|
val ctx = MessageContext(origin = SYSTEM, description = "ping")
|
||||||
|
// Симулируем: модель получает картинку + контекст — контекст идёт первой Text-частью.
|
||||||
|
val parts = listOf<LiteContentPart>(LiteContentPart.Text("just prefix"))
|
||||||
|
val out = applyContextPrefix(parts, ctx)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
val text = assertIs<LiteContentPart.Text>(out[0])
|
||||||
|
assertEquals("[SYSTEM] ping\njust prefix", text.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatContextPrefix formats name + description + sourceId`() {
|
||||||
|
val ctx = MessageContext(origin = EVENT, description = "wake", sourceId = "cron-1")
|
||||||
|
assertEquals("[EVENT] wake (sourceId=cron-1)", formatContextPrefix(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatContextPrefix omits blank description and sourceId`() {
|
||||||
|
val ctx = MessageContext(origin = SYSTEM)
|
||||||
|
assertEquals("[SYSTEM]", formatContextPrefix(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatContextPrefix omits blank sourceId even if description is set`() {
|
||||||
|
val ctx = MessageContext(origin = SYSTEM, description = "boot", sourceId = "")
|
||||||
|
assertEquals("[SYSTEM] boot", formatContextPrefix(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `USER origin with context fields still produces no prefix`() {
|
||||||
|
// Контекст с USER-происхождением, но с заполненным description/sourceId:
|
||||||
|
// не должен триггерить префикс (UI-метаданные для логирования).
|
||||||
|
val ctx = MessageContext(origin = USER, sourceId = "irc:agentik", description = "PRIVMSG")
|
||||||
|
val parts = listOf(LiteContentPart.Text("hi"))
|
||||||
|
val out = applyContextPrefix(parts, ctx)
|
||||||
|
assertEquals(parts, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательное для теста
|
||||||
|
@Test
|
||||||
|
fun `null-context assert helper`() {
|
||||||
|
assertNull(null as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
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, Map<String, Any?>>>) : 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 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 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() {}
|
||||||
|
}
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.io.files.Path
|
||||||
|
import kotlinx.io.files.SystemFileSystem
|
||||||
|
import kotlinx.io.files.SystemTemporaryDirectory
|
||||||
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemoryPrefetcher
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySearchResult
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import pw.binom.agentik.memory.MemorySystemGuidance
|
||||||
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
import pw.binom.agentik.memory.md.openMdMemorySystem
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmConfig
|
||||||
|
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
||||||
|
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||||
|
import pw.binom.agentik.llm.tools.SummaryTurn
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграция памяти в :standalone:
|
||||||
|
* - тулы memory_save/read/list/delete регистрируются у агента
|
||||||
|
* - prefetcher вставляет контекст в первое user-сообщение
|
||||||
|
* - reviewer пишет факты в store после хода
|
||||||
|
*/
|
||||||
|
class MemoryWiringTest {
|
||||||
|
|
||||||
|
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
private lateinit var fakeLlm: FakeLiteLlm
|
||||||
|
private lateinit var root: Path
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
|
||||||
|
fakeLlm = FakeLiteLlm()
|
||||||
|
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
|
||||||
|
SystemFileSystem.createDirectories(root, mustCreate = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
sqliteStores.close()
|
||||||
|
runCatching { SystemFileSystem.delete(root, mustExist = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newAgent(
|
||||||
|
memoryStore: MemoryStore,
|
||||||
|
prefetcher: MemoryPrefetcher,
|
||||||
|
reviewer: MemoryReviewer,
|
||||||
|
contextWindow: Int? = null,
|
||||||
|
compressionThreshold: Double = 0.8,
|
||||||
|
contextCompactor: ContextCompactor = EchoCompactor,
|
||||||
|
): ChatAgent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
memoryStore = memoryStore,
|
||||||
|
memoryPrefetcher = prefetcher,
|
||||||
|
memoryReviewer = reviewer,
|
||||||
|
contextWindow = contextWindow,
|
||||||
|
compressionThreshold = compressionThreshold,
|
||||||
|
contextCompactor = contextCompactor,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `system prompt includes memory guidance when memory is enabled`() = runBlocking {
|
||||||
|
val system = openMdMemorySystem(root)
|
||||||
|
val agent = newAgent(system.store, system.prefetcher, system.reviewer)
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
// System prompt не пишется в working_memory — читаем то, что увидит LLM
|
||||||
|
val text = fakeLlm.lastConfig?.systemInstruction
|
||||||
|
assertNotNull(text)
|
||||||
|
assertTrue(text!!.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
|
||||||
|
"system prompt should contain MEMORY_GUIDANCE; got first 200 chars: ${text.take(200)}")
|
||||||
|
agent.close()
|
||||||
|
system.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `soul body is prepended to system prompt and wins over base`() = runBlocking {
|
||||||
|
val soulBody = "I am a helpful test persona. I always answer in one short line."
|
||||||
|
val agent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
soulBody = soulBody,
|
||||||
|
)
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
val text = fakeLlm.lastConfig?.systemInstruction
|
||||||
|
assertNotNull(text)
|
||||||
|
assertTrue(text!!.startsWith(soulBody),
|
||||||
|
"soul should be the very first section; got first 60 chars: ${text.take(60)}")
|
||||||
|
assertTrue(text.contains("be brief"),
|
||||||
|
"base prompt should still follow the soul; got: $text")
|
||||||
|
agent.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `soul body not added when null`() = runBlocking {
|
||||||
|
val agent = ChatAgent(
|
||||||
|
id = "agentik",
|
||||||
|
mutableConversationStore = sqliteStores.conversations,
|
||||||
|
messageStore = sqliteStores.messages,
|
||||||
|
workingMemoryStore = sqliteStores.workingMemory,
|
||||||
|
reflectionStore = sqliteStores.reflections,
|
||||||
|
llm = fakeLlm,
|
||||||
|
llmConfig = LlmConfig(
|
||||||
|
backend = LlmBackend.OPENAI,
|
||||||
|
systemPrompt = "be brief",
|
||||||
|
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val conv = agent.createConversation(temp = false)
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
conv.send(listOf(Content.Text("hi")))
|
||||||
|
val text = fakeLlm.lastConfig?.systemInstruction
|
||||||
|
assertNotNull(text)
|
||||||
|
assertTrue(text!!.startsWith("be brief"),
|
||||||
|
"without soul, prompt should start with base; got first 60 chars: ${text.take(60)}")
|
||||||
|
agent.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `agent exposes memory tools when store is configured`() {
|
||||||
|
val system = openMdMemorySystem(root)
|
||||||
|
val tools = MemoryToolsFactory.create(system.store)
|
||||||
|
assertEquals(4, tools.size)
|
||||||
|
val names = tools.map { it.name }.toSet()
|
||||||
|
assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names)
|
||||||
|
// Каждый tool описывается валидной JSON-схемой:
|
||||||
|
for (t in tools) {
|
||||||
|
assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}")
|
||||||
|
}
|
||||||
|
system.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `memory_save tool round-trips a note through the store`() = runBlocking {
|
||||||
|
val system = openMdMemorySystem(root)
|
||||||
|
val tools = MemoryToolsFactory.create(system.store).associateBy { it.name }
|
||||||
|
val saveResult = tools.getValue("memory_save").tool.invoke(
|
||||||
|
"""{"category":"preference","content":"prefers tabs over spaces"}""",
|
||||||
|
)
|
||||||
|
assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult")
|
||||||
|
assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult")
|
||||||
|
|
||||||
|
val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""")
|
||||||
|
assertTrue(listResult.contains("prefers tabs over spaces"),
|
||||||
|
"list returned: $listResult")
|
||||||
|
|
||||||
|
val readResult = tools.getValue("memory_read").tool.invoke(
|
||||||
|
"""{"query":"tabs","top_k":3}""",
|
||||||
|
)
|
||||||
|
assertTrue(readResult.contains("prefers tabs over spaces"),
|
||||||
|
"read returned: $readResult")
|
||||||
|
|
||||||
|
val deleteResult = tools.getValue("memory_delete").tool.invoke(
|
||||||
|
Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m ->
|
||||||
|
"""{"id":"${m.groupValues[1]}"}"""
|
||||||
|
} ?: error("save did not return id"),
|
||||||
|
)
|
||||||
|
assertTrue(deleteResult.contains("\"ok\":true"), "delete returned: $deleteResult")
|
||||||
|
system.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prefetch inserts memory context into first user message`() = runTest {
|
||||||
|
// Сидим факт в store.
|
||||||
|
val store = openMdMemorySystem(root).also {
|
||||||
|
it.store.upsert(
|
||||||
|
MemoryNote(
|
||||||
|
id = "mem-pre",
|
||||||
|
category = MemoryCategory.USER,
|
||||||
|
content = "User runs k3s on Debian",
|
||||||
|
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
|
||||||
|
useCount = 0,
|
||||||
|
source = MemorySource.AGENT_SAVE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val prefetcher = StaticPrefetcher { q, k ->
|
||||||
|
store.store.search(MemorySearchQuery(query = q, topK = k, category = null))
|
||||||
|
}
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
|
||||||
|
val agent = newAgent(store.store, prefetcher, NoopReviewer())
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
conv.send(listOf(Content.Text("what's my k3s setup?")))
|
||||||
|
|
||||||
|
val sentText = fakeLlm.lastContents?.filterIsInstance<pw.binom.litert.LiteContentPart.Text>()
|
||||||
|
?.joinToString("\n") { it.text }
|
||||||
|
assertNotNull(sentText)
|
||||||
|
assertTrue(sentText.startsWith("[Memory context"),
|
||||||
|
"user message should start with memory prefix, got: $sentText")
|
||||||
|
assertTrue(sentText.contains("User runs k3s on Debian"),
|
||||||
|
"user message should include the prefetched note, got: $sentText")
|
||||||
|
assertTrue(sentText.contains("what's my k3s setup?"),
|
||||||
|
"user message should still contain the original text after the prefix, got: $sentText")
|
||||||
|
agent.close()
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `prefetch does not add prefix when no hits`() = runTest {
|
||||||
|
val store = openMdMemorySystem(root)
|
||||||
|
val prefetcher = StaticPrefetcher { _, _ -> emptyList() }
|
||||||
|
fakeLlm.reply = "ok"
|
||||||
|
|
||||||
|
val agent = newAgent(store.store, prefetcher, NoopReviewer())
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
conv.send(listOf(Content.Text("hello")))
|
||||||
|
|
||||||
|
val sentText = fakeLlm.lastContents?.filterIsInstance<pw.binom.litert.LiteContentPart.Text>()
|
||||||
|
?.joinToString("\n") { it.text }
|
||||||
|
assertNotNull(sentText)
|
||||||
|
assertTrue(!sentText.startsWith("[Memory context"),
|
||||||
|
"user message should not start with prefix when no hits, got: $sentText")
|
||||||
|
assertTrue(sentText.contains("hello"))
|
||||||
|
agent.close()
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reviewer fires on compaction (event-driven), not on every turn`() = runTest {
|
||||||
|
// После перехода на event-driven: review fires ТОЛЬКО в CompactionCoordinator.compactPreTurn()
|
||||||
|
// (через reviewer.reviewPreCompaction()) — не на каждый turn, не по interval'у.
|
||||||
|
// На простом turn без compaction review НЕ запускается.
|
||||||
|
val store = openMdMemorySystem(root)
|
||||||
|
fakeLlm.reply = "Sure, I'll remember that."
|
||||||
|
val reviewerInvoked = CompletableDeferred<Unit>()
|
||||||
|
val reviewer = object : MemoryReviewer {
|
||||||
|
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
|
||||||
|
error("review() не должен вызываться — только reviewPreCompaction() на compaction")
|
||||||
|
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
|
||||||
|
reviewerInvoked.complete(Unit)
|
||||||
|
return MemoryReviewDecision(
|
||||||
|
toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")),
|
||||||
|
toDelete = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val agent = newAgent(
|
||||||
|
store.store,
|
||||||
|
StaticPrefetcher { _, _ -> emptyList() },
|
||||||
|
reviewer,
|
||||||
|
contextWindow = 1024, // forceCompactNow требует contextWindow
|
||||||
|
)
|
||||||
|
val conv = agent.createConversation(temp = false) as ChatConversation
|
||||||
|
|
||||||
|
// Отправляем turn — ничего не должно триггериться, т.к. event-driven review
|
||||||
|
// fires только на compaction/closing.
|
||||||
|
conv.send(listOf(Content.Text("please note: I prefer k8s over docker swarm")))
|
||||||
|
|
||||||
|
// Ждём немного, проверяем что reviewer.review() НЕ был вызван
|
||||||
|
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
||||||
|
delay(200)
|
||||||
|
}
|
||||||
|
assertTrue(store.store.list(category = MemoryCategory.PREFERENCE).isEmpty(),
|
||||||
|
"review НЕ должен был вызваться на простом turn без compaction")
|
||||||
|
|
||||||
|
// Триггерим compaction вручную (через debug-API ConversationLoop.forceCompactNow).
|
||||||
|
// Это вызывает CompactionCoordinator.compactPreTurn → reviewer.reviewPreCompaction.
|
||||||
|
conv.forceCompactNow()
|
||||||
|
|
||||||
|
// Дожидаемся reviewPreCompaction и upsert'а в IO-диспетчере.
|
||||||
|
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
||||||
|
withTimeout(2_000) { reviewerInvoked.await() }
|
||||||
|
withTimeout(2_000) {
|
||||||
|
while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val notes = store.store.list(category = MemoryCategory.PREFERENCE)
|
||||||
|
assertEquals(1, notes.size, "reviewPreCompaction должен сохранить заметку")
|
||||||
|
assertEquals("prefers k8s", notes[0].content)
|
||||||
|
assertEquals(MemorySource.AUTO_REVIEW, notes[0].source)
|
||||||
|
agent.close()
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private class StaticPrefetcher(
|
||||||
|
private val fn: suspend (String, Int) -> List<MemorySearchResult>,
|
||||||
|
) : MemoryPrefetcher {
|
||||||
|
override suspend fun prefetch(query: String, topK: Int, category: MemoryCategory?): List<MemoryNote> {
|
||||||
|
if (query.isBlank()) return emptyList()
|
||||||
|
return fn(query, topK).map { it.note }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NoopReviewer : MemoryReviewer {
|
||||||
|
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
|
||||||
|
MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Простой compactor для тестов: возвращает статичную строку. */
|
||||||
|
private object EchoCompactor : ContextCompactor {
|
||||||
|
override suspend fun summarize(turns: List<SummaryTurn>): String =
|
||||||
|
if (turns.isEmpty()) "" else "compacted-${turns.size}-turns"
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import pw.binom.agentik.llm.tools.ReflectionParser
|
||||||
|
|
||||||
|
class ReflectionParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses clean JSON`() {
|
||||||
|
val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}"""
|
||||||
|
val p = ReflectionParser.parse(raw)
|
||||||
|
assertNotNull(p)
|
||||||
|
assertEquals(4, p.score)
|
||||||
|
assertEquals("ok", p.summary)
|
||||||
|
assertEquals(listOf("a", "b"), p.weakSpots)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses JSON wrapped in json fences`() {
|
||||||
|
val raw = "```json\n" +
|
||||||
|
"{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" +
|
||||||
|
"```"
|
||||||
|
val p = ReflectionParser.parse(raw)
|
||||||
|
assertNotNull(p)
|
||||||
|
assertEquals(3, p.score)
|
||||||
|
assertEquals(listOf<String>(), p.weakSpots)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses JSON with leading and trailing text`() {
|
||||||
|
val raw = "Вот мой ответ:\n" +
|
||||||
|
"{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" +
|
||||||
|
"Конец."
|
||||||
|
val p = ReflectionParser.parse(raw)
|
||||||
|
assertNotNull(p)
|
||||||
|
assertEquals(2, p.score)
|
||||||
|
assertEquals(listOf("путаю", "медленно"), p.weakSpots)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accepts score as string`() {
|
||||||
|
val raw = """{"score": "5", "summary": "ok", "weakSpots": []}"""
|
||||||
|
val p = ReflectionParser.parse(raw)
|
||||||
|
assertNotNull(p)
|
||||||
|
assertEquals(5, p.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns null on missing score`() {
|
||||||
|
val raw = """{"summary": "x", "weakSpots": []}"""
|
||||||
|
assertNull(ReflectionParser.parse(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns null on invalid JSON`() {
|
||||||
|
assertNull(ReflectionParser.parse("not even json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles escape sequences in weakSpots`() {
|
||||||
|
// raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате
|
||||||
|
val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}"""
|
||||||
|
val p = ReflectionParser.parse(raw)
|
||||||
|
assertNotNull(p)
|
||||||
|
// парсер снимает один escape: \\\\ → \\
|
||||||
|
assertEquals(listOf("path\\\\file"), p.weakSpots)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.agentik.llm.tools.SkillMiner
|
||||||
|
|
||||||
|
class SkillMinerTest {
|
||||||
|
|
||||||
|
private fun turns(n: Int): List<ConversationTurn> = (1..n).map {
|
||||||
|
ConversationTurn(userMessage = "q$it", assistantMessage = "a$it")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mine returns parsed skills from model JSON reply`() = runBlocking {
|
||||||
|
val llm = FakeLiteLlm()
|
||||||
|
llm.reply = """{"skills": [{"name": "n", "description": "d", "body": "b"}]}"""
|
||||||
|
val miner = SkillMiner(llm, maxTurns = 10, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||||
|
val out = miner.mine(turns(5), existing = emptyList())
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
assertEquals("n", out[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mine with empty reply returns empty`() = runBlocking {
|
||||||
|
val llm = FakeLiteLlm()
|
||||||
|
llm.reply = "{}"
|
||||||
|
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||||
|
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `miner failure degrades to empty list`() = runBlocking {
|
||||||
|
val llm = FakeLiteLlm()
|
||||||
|
llm.failMessage = "onnx died"
|
||||||
|
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||||
|
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `miner respects maxTurns`() = runBlocking {
|
||||||
|
val llm = FakeLiteLlm()
|
||||||
|
llm.reply = """{"skills": []}"""
|
||||||
|
val miner = SkillMiner(llm, maxTurns = 2, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||||
|
miner.mine(turns(30), emptyList())
|
||||||
|
val prompt = llm.lastContents!!.first().let {
|
||||||
|
val p = it as pw.binom.litert.LiteContentPart.Text
|
||||||
|
p.text
|
||||||
|
}
|
||||||
|
// В промпт попало только последние 2 хода из 30.
|
||||||
|
assertTrue(prompt.contains("q29"), "last turns missing: $prompt")
|
||||||
|
assertTrue(!prompt.contains("q1\n"), "old turn leaked: $prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty turns short-circuit without LLM call`() = runBlocking {
|
||||||
|
val llm = FakeLiteLlm()
|
||||||
|
llm.failMessage = "should not be called"
|
||||||
|
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||||
|
val out = miner.mine(emptyList(), emptyList())
|
||||||
|
assertTrue(out.isEmpty())
|
||||||
|
assertTrue(llm.conversations.isEmpty(), "LLM must not be called for empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.agentik.llm.tools.SkillMiningParser
|
||||||
|
|
||||||
|
class SkillMiningParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses clean JSON`() {
|
||||||
|
val raw = """{"skills": [{"name": "backend:spring:db", "description": "x", "body": "# step 1"}]}"""
|
||||||
|
val out = SkillMiningParser.parse(raw)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
assertEquals("backend:spring:db", out[0].name)
|
||||||
|
assertEquals("x", out[0].description)
|
||||||
|
assertEquals("# step 1", out[0].body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses JSON wrapped in fences with prose around`() {
|
||||||
|
val raw = "Окей, вот что я нашёл:\n```json\n" +
|
||||||
|
"{\"skills\": [{\"name\": \"a\", \"description\": \"d\", \"body\": \"b\"}]}\n" +
|
||||||
|
"```\nНадеюсь, помогло."
|
||||||
|
val out = SkillMiningParser.parse(raw)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
assertEquals("a", out[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses bare array without skills key`() {
|
||||||
|
val raw = """[{"name": "x", "description": "d", "body": "b"}, {"name": "y"}]"""
|
||||||
|
val out = SkillMiningParser.parse(raw)
|
||||||
|
assertEquals(2, out.size)
|
||||||
|
assertEquals("x", out[0].name)
|
||||||
|
assertEquals("", out[1].body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unescapes newlines and quotes in body`() {
|
||||||
|
val raw = "{\"skills\": [{\"name\": \"n\", \"description\": \"\", \"body\": \"line1\\nline2\\n\\nwith \\\"quotes\\\" and backslash \\\\\\\"\"}]}"
|
||||||
|
val out = SkillMiningParser.parse(raw)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
val body = out[0].body
|
||||||
|
assertTrue(body.contains("line1\nline2"), "body: $body")
|
||||||
|
assertTrue(body.contains("\"quotes\""), "body: $body")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty skills array returns empty list`() {
|
||||||
|
val out = SkillMiningParser.parse("""{"skills": []}""")
|
||||||
|
assertTrue(out.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `model chatter with no JSON returns empty`() {
|
||||||
|
val out = SkillMiningParser.parse("Скилов не нашёл, всё чисто.")
|
||||||
|
assertTrue(out.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncated JSON returns empty`() {
|
||||||
|
val out = SkillMiningParser.parse("""{"skills": [{"name": "a", "description": "d", "body": """"")
|
||||||
|
assertTrue(out.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skills without name are dropped`() {
|
||||||
|
val raw = """{"skills": [{"description": "no name"}, {"name": "ok"}]}"""
|
||||||
|
val out = SkillMiningParser.parse(raw)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
assertEquals("ok", out[0].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nested braces inside strings do not break balance`() {
|
||||||
|
val raw = """{"skills": [{"name": "n", "description": "d", "body": "echo '{\"k\": 1}'"}]}"""
|
||||||
|
val out = SkillMiningParser.parse(raw)
|
||||||
|
assertEquals(1, out.size)
|
||||||
|
assertEquals("echo '{\"k\": 1}'", out[0].body)
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
|
import pw.binom.agentik.skills.SkillFile
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SkillReadToolTest {
|
||||||
|
|
||||||
|
private val catalog = SkillCatalog(
|
||||||
|
listOf(
|
||||||
|
SkillFile(name = "lint", description = "lint things", body = "# Lint\nRun the linter."),
|
||||||
|
SkillFile(name = "empty", description = "no body", body = ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
private val tool = SkillReadTool(catalog)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun describeIsFlatOpenApiSchema() {
|
||||||
|
val json = tool.describe()
|
||||||
|
// Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
|
||||||
|
assertTrue("\"name\"" in json)
|
||||||
|
assertTrue(SkillReadTool.NAME in json)
|
||||||
|
assertTrue("parameters" in json)
|
||||||
|
assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" in json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeReturnsBodyForKnownSkill() {
|
||||||
|
val result = tool.invoke("""{"name":"lint"}""")
|
||||||
|
assertEquals("# Lint\nRun the linter.", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeUnknownSkillListsAvailable() {
|
||||||
|
val result = tool.invoke("""{"name":"nope"}""")
|
||||||
|
assertTrue("unknown skill" in result)
|
||||||
|
assertTrue("lint" in result)
|
||||||
|
assertTrue("empty" in result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeEmptyBodyGivesPlaceholder() {
|
||||||
|
val result = tool.invoke("""{"name":"empty"}""")
|
||||||
|
assertTrue("empty body" in result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeMissingNameIsError() {
|
||||||
|
val result = tool.invoke("{}")
|
||||||
|
assertTrue("[tool error]" in result)
|
||||||
|
assertTrue("name" in result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeInvalidJsonIsError() {
|
||||||
|
assertTrue("[tool error]" in tool.invoke("not json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeBlankArgumentsIsError() {
|
||||||
|
assertTrue("[tool error]" in tool.invoke(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.skills.DiskSkillStore
|
||||||
|
import pw.binom.agentik.skills.SkillCatalog
|
||||||
|
import pw.binom.agentik.skills.SkillFile
|
||||||
|
import pw.binom.agentik.skills.SkillStore
|
||||||
|
|
||||||
|
class SkillToolsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SkillSaveTool persists and surfaces in catalog`() {
|
||||||
|
val dir = tempSkillDir()
|
||||||
|
val store: SkillStore = DiskSkillStore(dir)
|
||||||
|
val tool = SkillSaveTool(store)
|
||||||
|
|
||||||
|
val arguments = """{"name":"my-skill","description":"Test skill","body":"# Hello"}"""
|
||||||
|
val result = tool.invoke(arguments)
|
||||||
|
assertTrue(result.contains("\"ok\":true"), "expected success, got: $result")
|
||||||
|
assertTrue(result.contains("my-skill"))
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val reloaded = SkillCatalog(store.catalog.skills)
|
||||||
|
assertEquals(1, reloaded.skills.size)
|
||||||
|
val s = reloaded.skills.first()
|
||||||
|
assertEquals("my-skill", s.name)
|
||||||
|
assertEquals("Test skill", s.description)
|
||||||
|
assertEquals("# Hello", s.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Должен появиться файл на диске
|
||||||
|
assertTrue(File(dir, "my-skill/SKILL.md").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SkillSaveTool rejects blank name`() {
|
||||||
|
val dir = tempSkillDir()
|
||||||
|
val store = DiskSkillStore(dir)
|
||||||
|
val tool = SkillSaveTool(store)
|
||||||
|
val arguments = """{"name":"","description":"x","body":"y"}"""
|
||||||
|
// Ожидаем ошибку
|
||||||
|
try {
|
||||||
|
tool.invoke(arguments)
|
||||||
|
error("should have thrown")
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertTrue(e.message!!.contains("skill name", ignoreCase = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SkillDeleteTool archives the skill`() {
|
||||||
|
val dir = tempSkillDir()
|
||||||
|
val store = DiskSkillStore(dir)
|
||||||
|
store.upsert(SkillFile(name = "to-delete", description = "x", body = "y"))
|
||||||
|
|
||||||
|
val tool = SkillDeleteTool(store)
|
||||||
|
val result = tool.invoke("""{"name":"to-delete"}""")
|
||||||
|
assertTrue(result.contains("\"ok\":true"))
|
||||||
|
assertTrue(result.contains("to-delete"))
|
||||||
|
|
||||||
|
// Скил исчез из каталога
|
||||||
|
assertTrue(store.catalog.skills.none { it.name == "to-delete" })
|
||||||
|
// Файл переименован в .archived
|
||||||
|
assertTrue(File(dir, "to-delete/SKILL.md.archived").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SkillDeleteTool returns error for missing skill`() {
|
||||||
|
val dir = tempSkillDir()
|
||||||
|
val store = DiskSkillStore(dir)
|
||||||
|
val tool = SkillDeleteTool(store)
|
||||||
|
try {
|
||||||
|
tool.invoke("""{"name":"does-not-exist"}""")
|
||||||
|
error("should have thrown")
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertTrue(e.message!!.contains("not found", ignoreCase = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `colon-named skills map to nested directories`() {
|
||||||
|
val dir = tempSkillDir()
|
||||||
|
val store = DiskSkillStore(dir)
|
||||||
|
store.upsert(SkillFile(name = "backend:spring:db-base", description = "x", body = "y"))
|
||||||
|
assertTrue(File(dir, "backend/spring/db-base/SKILL.md").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tempSkillDir(): File {
|
||||||
|
val dir = File(System.getProperty("java.io.tmpdir"), "agentik-skills-${Uuid.random()}")
|
||||||
|
dir.deleteOnExit()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySearchQuery
|
||||||
|
import pw.binom.agentik.memory.MemorySearchResult
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простой in-memory MemoryStore для тестов.
|
||||||
|
*
|
||||||
|
* Не зависит от :memory-md / :memory-vector; идёт через `archiveStale` из
|
||||||
|
* default-имплементации MemoryStore.
|
||||||
|
*/
|
||||||
|
class TestInMemoryMemoryStore : MemoryStore {
|
||||||
|
private val notes = mutableMapOf<String, MemoryNote>()
|
||||||
|
private val ev = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
|
||||||
|
|
||||||
|
override suspend fun upsert(note: MemoryNote) {
|
||||||
|
notes[note.id] = note
|
||||||
|
ev.tryEmit(MemoryStoreEvent.Upserted(note))
|
||||||
|
}
|
||||||
|
override suspend fun get(id: String): MemoryNote? = notes[id]
|
||||||
|
override suspend fun list(
|
||||||
|
category: MemoryCategory?,
|
||||||
|
conversationId: String?,
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<MemoryNote> =
|
||||||
|
notes.values
|
||||||
|
.filter { category == null || it.category == category }
|
||||||
|
.drop(offset)
|
||||||
|
.take(limit)
|
||||||
|
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> =
|
||||||
|
notes.values
|
||||||
|
.filter { query.category == null || it.category == query.category }
|
||||||
|
.map { MemorySearchResult(it, 1.0f) }
|
||||||
|
.take(query.topK)
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
val ok = notes.remove(id) != null
|
||||||
|
if (ok) ev.tryEmit(MemoryStoreEvent.Deleted(id))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
override suspend fun markUsed(id: String, at: Instant) {
|
||||||
|
notes[id]?.let { notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) }
|
||||||
|
}
|
||||||
|
override fun events(): Flow<MemoryStoreEvent> = ev.asSharedFlow()
|
||||||
|
override fun close() {}
|
||||||
|
fun allIds(): List<String> = notes.keys.sorted()
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.standalone.agent.TestInMemoryMemoryStore
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
|
||||||
|
class CuratorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `archives notes older than maxAge with zero useCount`() = runTest {
|
||||||
|
val store = TestInMemoryMemoryStore()
|
||||||
|
val now = Instant.parse("2026-09-15T00:00:00Z")
|
||||||
|
store.upsert(note("old", lastUsedOffsetDays = 100, useCount = 0))
|
||||||
|
store.upsert(note("fresh", lastUsedOffsetDays = 1, useCount = 0))
|
||||||
|
val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now))
|
||||||
|
assertEquals(1, curator.runPass())
|
||||||
|
assertEquals(listOf("fresh"), store.allIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps notes that were used recently even if old`() = runTest {
|
||||||
|
val store = TestInMemoryMemoryStore()
|
||||||
|
val now = Instant.parse("2026-09-15T00:00:00Z")
|
||||||
|
store.upsert(note("frequently-used", lastUsedOffsetDays = 1, useCount = 50))
|
||||||
|
val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now))
|
||||||
|
assertEquals(0, curator.runPass())
|
||||||
|
assertEquals(listOf("frequently-used"), store.allIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `archives notes with useCount above default when configured`() = runTest {
|
||||||
|
val store = TestInMemoryMemoryStore()
|
||||||
|
val now = Instant.parse("2026-09-15T00:00:00Z")
|
||||||
|
store.upsert(note("twice-used", lastUsedOffsetDays = 100, useCount = 2))
|
||||||
|
val curator = Curator(
|
||||||
|
store,
|
||||||
|
maxAge = Duration.parse("90d"),
|
||||||
|
maxUseCount = 5,
|
||||||
|
clock = FakeClock(now),
|
||||||
|
)
|
||||||
|
assertEquals(1, curator.runPass())
|
||||||
|
assertTrue(store.allIds().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `start and stop launch and cancel the background loop`() = runTest {
|
||||||
|
val store = TestInMemoryMemoryStore()
|
||||||
|
val curator = Curator(
|
||||||
|
store,
|
||||||
|
interval = Duration.parse("10ms"),
|
||||||
|
maxAge = Duration.parse("90d"),
|
||||||
|
)
|
||||||
|
curator.start()
|
||||||
|
Thread.sleep(50)
|
||||||
|
curator.stop()
|
||||||
|
// ничего не падает, корутина отменена
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun note(
|
||||||
|
id: String,
|
||||||
|
lastUsedOffsetDays: Long,
|
||||||
|
useCount: Int,
|
||||||
|
): MemoryNote {
|
||||||
|
val now = Instant.parse("2026-09-15T00:00:00Z")
|
||||||
|
return MemoryNote(
|
||||||
|
id = id,
|
||||||
|
category = MemoryCategory.WORLD,
|
||||||
|
content = "fact $id",
|
||||||
|
createdAt = now - Duration.parse("${lastUsedOffsetDays}d"),
|
||||||
|
lastUsedAt = now - Duration.parse("${lastUsedOffsetDays}d"),
|
||||||
|
useCount = useCount,
|
||||||
|
conversationId = null,
|
||||||
|
source = MemorySource.AGENT_SAVE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeClock(private val now: Instant) : Clock {
|
||||||
|
override fun now(): Instant = now
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
import pw.binom.agentik.standalone.agent.FakeLiteLlm
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
|
||||||
|
import pw.binom.agentik.llm.tools.ReviewPrompts
|
||||||
|
|
||||||
|
class LlmMemoryReviewerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный in-memory store для тестов — реализует [MemoryStore],
|
||||||
|
* хранит заметки в MutableList, поддерживает events flow.
|
||||||
|
*/
|
||||||
|
private class InMemoryStore : MemoryStore {
|
||||||
|
private val notes = mutableMapOf<String, MemoryNote>()
|
||||||
|
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
|
||||||
|
|
||||||
|
override suspend fun upsert(note: MemoryNote) {
|
||||||
|
notes[note.id] = note
|
||||||
|
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(id: String): MemoryNote? = notes[id]
|
||||||
|
override suspend fun list(
|
||||||
|
category: MemoryCategory?,
|
||||||
|
conversationId: String?,
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<MemoryNote> = notes.values
|
||||||
|
.filter { category == null || it.category == category }
|
||||||
|
.filter { conversationId == null || it.conversationId == conversationId }
|
||||||
|
.sortedByDescending { it.lastUsedAt }
|
||||||
|
.drop(offset)
|
||||||
|
.take(limit)
|
||||||
|
|
||||||
|
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean = notes.remove(id) != null
|
||||||
|
|
||||||
|
override suspend fun markUsed(id: String, at: Instant) {
|
||||||
|
notes[id]?.let {
|
||||||
|
notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(): kotlinx.coroutines.flow.Flow<MemoryStoreEvent> = _events
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
|
||||||
|
// Helper for tests to seed notes
|
||||||
|
fun seed(note: MemoryNote) {
|
||||||
|
notes[note.id] = note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review parses save JSON and applies upsert`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}"""
|
||||||
|
}
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(
|
||||||
|
ReviewedTurn(
|
||||||
|
userMessage = "Меня Саша зовут",
|
||||||
|
assistantMessage = "Приятно познакомиться, Саша!",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||||
|
|
||||||
|
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||||
|
assertEquals(1, applied.saved)
|
||||||
|
|
||||||
|
val all = store.list()
|
||||||
|
assertEquals(1, all.size)
|
||||||
|
assertEquals("Имя — Саша", all[0].content)
|
||||||
|
assertEquals(MemorySource.AUTO_REVIEW, all[0].source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review applies delete decisions`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """{"save":[],"delete":["mem-stale-1"]}"""
|
||||||
|
}
|
||||||
|
val store = InMemoryStore().apply {
|
||||||
|
seed(
|
||||||
|
MemoryNote(
|
||||||
|
id = "mem-stale-1",
|
||||||
|
category = MemoryCategory.USER,
|
||||||
|
content = "stale",
|
||||||
|
createdAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
useCount = 0,
|
||||||
|
source = MemorySource.AUTO_REVIEW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(ReviewedTurn("удали это", "ок"))
|
||||||
|
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||||
|
assertEquals(0, applied.saved)
|
||||||
|
assertEquals(1, applied.deleted)
|
||||||
|
assertEquals(0, store.list().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review returns empty decision when LLM produces garbage`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." }
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
assertTrue(decision.toDelete.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review handles empty LLM reply`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply { reply = "" }
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review creates conversation with review system prompt`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """{"save":[],"delete":[]}"""
|
||||||
|
}
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
reviewer.review(ReviewedTurn("u", "a"))
|
||||||
|
|
||||||
|
assertNotNull(llm.lastConfig)
|
||||||
|
assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reviewPreCompaction processes batch of turns`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """
|
||||||
|
{"save":[
|
||||||
|
{"category":"USER","content":"Работает в Яндексе"},
|
||||||
|
{"category":"WORLD","content":"JVector — pure-Java ANN"}
|
||||||
|
],"delete":[]}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val turns = listOf(
|
||||||
|
pw.binom.agentik.memory.ConversationTurn(
|
||||||
|
userMessage = "Я в Яндексе работаю",
|
||||||
|
assistantMessage = "Круто!",
|
||||||
|
),
|
||||||
|
pw.binom.agentik.memory.ConversationTurn(
|
||||||
|
userMessage = "А что за JVector?",
|
||||||
|
assistantMessage = "ANN-библиотека на Java.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = reviewer.reviewPreCompaction(turns)
|
||||||
|
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||||
|
|
||||||
|
assertEquals(2, applied.saved)
|
||||||
|
assertEquals(2, store.list().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.agentik.llm.tools.ReviewDecisionParser
|
||||||
|
|
||||||
|
class ReviewDecisionParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses save array with USER category`() {
|
||||||
|
val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||||
|
assertEquals("Имя пользователя — Саша", decision.toSave[0].content)
|
||||||
|
assertTrue(decision.toDelete.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses all three categories`() {
|
||||||
|
val raw = """
|
||||||
|
{"save":[
|
||||||
|
{"category":"USER","content":"Работает в Яндексе"},
|
||||||
|
{"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"},
|
||||||
|
{"category":"PREFERENCE","content":"Отвечать кратко"}
|
||||||
|
],"delete":[]}
|
||||||
|
""".trimIndent()
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(3, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||||
|
assertEquals(MemoryCategory.WORLD, decision.toSave[1].category)
|
||||||
|
assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses delete array with ids`() {
|
||||||
|
val raw = """{"save":[],"delete":["mem-123","mem-456"]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
assertEquals(listOf("mem-123", "mem-456"), decision.toDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty decision on empty input`() {
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(""))
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty decision on non-JSON garbage`() {
|
||||||
|
val raw = "Извини, я не могу помочь с этим."
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty decision on malformed JSON`() {
|
||||||
|
val raw = """{"save":[{"category":"USER","content":"foo""" // truncated
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extracts JSON from markdown code block`() {
|
||||||
|
val raw = """
|
||||||
|
Вот JSON:
|
||||||
|
```json
|
||||||
|
{"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]}
|
||||||
|
```
|
||||||
|
""".trimIndent()
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.WORLD, decision.toSave[0].category)
|
||||||
|
assertEquals("SQLite 3.51", decision.toSave[0].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips entries with unknown category`() {
|
||||||
|
val raw = """{"save":[
|
||||||
|
{"category":"USER","content":"valid"},
|
||||||
|
{"category":"NOT_A_CATEGORY","content":"should be skipped"},
|
||||||
|
{"category":"WORLD","content":"valid too"}
|
||||||
|
],"delete":[]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(2, decision.toSave.size)
|
||||||
|
assertEquals("valid", decision.toSave[0].content)
|
||||||
|
assertEquals("valid too", decision.toSave[1].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips entries with blank content`() {
|
||||||
|
val raw = """{"save":[
|
||||||
|
{"category":"USER","content":""},
|
||||||
|
{"category":"WORLD","content":" "}
|
||||||
|
],"delete":[]}"""
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles escaped quotes in content`() {
|
||||||
|
val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals("Сказал \"привет\"", decision.toSave[0].content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package pw.binom.agentik.standalone.config
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class AppConfigTest {
|
||||||
|
|
||||||
|
private fun openAiEnv(
|
||||||
|
extra: Map<String, String> = emptyMap(),
|
||||||
|
): (String) -> String? = { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
else -> extra[name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `defaults applied when port and db path absent`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv())
|
||||||
|
assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port)
|
||||||
|
assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reads port and db path from env`() {
|
||||||
|
val cfg = AppConfig.fromEnv(
|
||||||
|
openAiEnv(mapOf("AGENTIK_PORT" to "9999", "AGENTIK_DB_PATH" to "/tmp/x.db")),
|
||||||
|
)
|
||||||
|
assertEquals(9999, cfg.agent.port)
|
||||||
|
assertEquals("/tmp/x.db", cfg.agent.dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank db path falls back to default`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " ")))
|
||||||
|
assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-numeric port falls back to default`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port")))
|
||||||
|
assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delegates llm to AppConfig fromEnv`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv())
|
||||||
|
assertEquals(LlmBackend.OPENAI, cfg.llm.backend)
|
||||||
|
assertEquals("gpt-4o-mini", cfg.llm.openai?.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `google backend is selected via env`() {
|
||||||
|
val cfg = AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"AGENTIK_LLM_BACKEND" -> "google"
|
||||||
|
"AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(LlmBackend.GOOGLE, cfg.llm.backend)
|
||||||
|
assertEquals("/models/gemma.litertlm", cfg.llm.google?.modelPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing required llm env propagates`() {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
AppConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mcp empty when no config path`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv())
|
||||||
|
assertTrue(cfg.mcp.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mcp loaded from file`() {
|
||||||
|
val tmp = kotlin.io.path.createTempFile(suffix = ".json").toFile()
|
||||||
|
try {
|
||||||
|
tmp.writeText(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"fetch": { "command": "uvx", "args": ["mcp-server-fetch"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_MCP_CONFIG" to tmp.absolutePath)))
|
||||||
|
assertEquals(1, cfg.mcp.servers.size)
|
||||||
|
assertEquals("fetch", cfg.mcp.servers.first().name)
|
||||||
|
} finally {
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skills dir defaults to null`() {
|
||||||
|
assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.skillsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skills dir read from env`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills")))
|
||||||
|
assertEquals("/skills", cfg.agent.skillsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank skills dir falls back to null`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " ")))
|
||||||
|
assertEquals(null, cfg.agent.skillsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `soul path defaults to null`() {
|
||||||
|
assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.soulPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `soul path read from env`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md")))
|
||||||
|
assertEquals("/etc/SOUL.md", cfg.agent.soulPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank soul path falls back to null`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " ")))
|
||||||
|
assertEquals(null, cfg.agent.soulPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `serialization round-trips through json`() {
|
||||||
|
val original = AppConfig.fromEnv(
|
||||||
|
openAiEnv(
|
||||||
|
mapOf(
|
||||||
|
"AGENTIK_PORT" to "7777",
|
||||||
|
"AGENTIK_DB_PATH" to "/tmp/x.db",
|
||||||
|
"AGENTIK_SYSTEM_PROMPT" to "be brief",
|
||||||
|
"AGENTIK_SKILLS_DIR" to "/skills",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).copy(
|
||||||
|
mcp = pw.binom.agentik.mcp.bridge.McpConfig(
|
||||||
|
servers = listOf(
|
||||||
|
pw.binom.agentik.mcp.bridge.McpServerSpec.Stdio(
|
||||||
|
name = "fetch",
|
||||||
|
command = "uvx",
|
||||||
|
args = listOf("mcp-server-fetch"),
|
||||||
|
),
|
||||||
|
pw.binom.agentik.mcp.bridge.McpServerSpec.Http(
|
||||||
|
name = "remote",
|
||||||
|
url = "https://example.com/mcp",
|
||||||
|
headers = mapOf("Authorization" to "Bearer x"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = Json { encodeDefaults = true }
|
||||||
|
val text = json.encodeToString(AppConfig.serializer(), original)
|
||||||
|
val restored = json.decodeFromString(AppConfig.serializer(), text)
|
||||||
|
|
||||||
|
assertEquals(original, restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressionThreshold defaults to 0_8 when env unset`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv())
|
||||||
|
assertEquals(0.8, cfg.memory.compressionThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressionThreshold parsed from env`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6")))
|
||||||
|
assertEquals(0.6, cfg.memory.compressionThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressionThreshold clamped between min and max`() {
|
||||||
|
val tooLow = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01")))
|
||||||
|
assertEquals(0.1, tooLow.memory.compressionThreshold)
|
||||||
|
val tooHigh = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5")))
|
||||||
|
assertEquals(0.99, tooHigh.memory.compressionThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compressionThreshold garbage falls back to default`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен")))
|
||||||
|
assertEquals(0.8, cfg.memory.compressionThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package pw.binom.agentik.standalone.llm
|
||||||
|
|
||||||
|
import pw.binom.agentik.standalone.config.AppConfig
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
|
class LlmConfigTest {
|
||||||
|
|
||||||
|
private fun openAiEnv(extra: Map<String, String> = emptyMap()): (String) -> String? = { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
else -> extra[name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv - happy path`() {
|
||||||
|
val cfg = AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
"AGENTIK_SYSTEM_PROMPT" -> "be brief"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.llm
|
||||||
|
assertEquals("be brief", cfg.systemPrompt)
|
||||||
|
assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv - falls back to default system prompt`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv()).llm
|
||||||
|
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv - missing base url throws`() {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv - missing api key throws`() {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv - missing model throws`() {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank system prompt from env falls back to default`() {
|
||||||
|
val cfg = AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
"AGENTIK_SYSTEM_PROMPT" -> " "
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.llm
|
||||||
|
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv - OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() {
|
||||||
|
val cfg = AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
"OPENAI_CONTEXT_WINDOW" -> "128000"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.llm
|
||||||
|
assertEquals(128_000, cfg.openai?.contextWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contextWindow - env parsed once and exposed via derived prop`() {
|
||||||
|
// Старый resolveContextWindow проверял "env wins over config" — но в новой
|
||||||
|
// модели env и config это одно и то же значение (env читается один раз в
|
||||||
|
// AppConfig.fromEnv и сохраняется в OpenAiConfig.contextWindow). Поэтому
|
||||||
|
// тут проверяем, что derived-prop LlmConfig.contextWindow правильно
|
||||||
|
// прокидывает значение из OpenAiConfig для активного бэкенда.
|
||||||
|
val cfg = AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
"OPENAI_CONTEXT_WINDOW" -> "64000"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.llm
|
||||||
|
assertEquals(64_000, cfg.contextWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contextWindow - returns null when nothing set`() {
|
||||||
|
val cfg = AppConfig.fromEnv(openAiEnv()).llm
|
||||||
|
assertEquals(null, cfg.contextWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv - OPENAI_CONTEXT_WINDOW garbage falls back to null`() {
|
||||||
|
val cfg = AppConfig.fromEnv { name ->
|
||||||
|
when (name) {
|
||||||
|
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
||||||
|
"OPENAI_API_KEY" -> "sk-test"
|
||||||
|
"OPENAI_MODEL" -> "gpt-4o-mini"
|
||||||
|
"OPENAI_CONTEXT_WINDOW" -> "не-число"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.llm
|
||||||
|
assertEquals(null, cfg.openai?.contextWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
package pw.binom.agentik.standalone.llm
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.cio.CIO
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.response.respondBytes
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.head
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.HttpHeaders as KH
|
||||||
|
import io.ktor.utils.io.toByteArray
|
||||||
|
import java.io.File
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
class ModelDownloaderTest {
|
||||||
|
|
||||||
|
private val payload = ByteArray(8192) { (it and 0xff).toByte() }
|
||||||
|
|
||||||
|
/** Поднимает fake-HTTP-server с поддержкой HEAD/GET/Range и возвращает `port`. */
|
||||||
|
private fun startFakeServer(): FakeServer {
|
||||||
|
val port = ServerSocket(0).use { it.localPort }
|
||||||
|
val server = embeddedServer(CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
head("/model.litertlm") {
|
||||||
|
call.response.headers.append(KH.AcceptRanges, "bytes")
|
||||||
|
call.response.headers.append(KH.ContentLength, payload.size.toString())
|
||||||
|
call.respondText("")
|
||||||
|
}
|
||||||
|
get("/model.litertlm") {
|
||||||
|
val range = call.request.headers[KH.Range]
|
||||||
|
if (range == null) {
|
||||||
|
call.response.headers.append(KH.ContentLength, payload.size.toString())
|
||||||
|
call.respondBytes(payload)
|
||||||
|
} else {
|
||||||
|
// Parse "bytes=N-"
|
||||||
|
val n = range.substringAfter("bytes=").substringBefore('-').toLong()
|
||||||
|
val slice = payload.copyOfRange(n.toInt(), payload.size)
|
||||||
|
call.response.status(HttpStatusCode.PartialContent)
|
||||||
|
call.response.headers.append(KH.ContentRange, "bytes $n-${payload.size - 1}/${payload.size}")
|
||||||
|
call.response.headers.append(KH.ContentLength, slice.size.toString())
|
||||||
|
call.respondBytes(slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start(wait = false)
|
||||||
|
return FakeServer(server, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tmpFile(): File {
|
||||||
|
val dir: Path = createTempDirectory(prefix = "agentik-test-")
|
||||||
|
return dir.resolve("model.litertlm").toFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download writes full file when no part exists`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val result = dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(payload.size.toLong(), result.bytes)
|
||||||
|
assertEquals(0L, result.resumedFrom)
|
||||||
|
assertContentEquals(payload, dest.readBytes())
|
||||||
|
assertTrue(!File("${dest.absolutePath}.part").exists(), "part file should be cleaned up")
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download is no-op when final file already complete`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
dest.writeBytes(payload)
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val result = dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(0L, result.bytes)
|
||||||
|
assertEquals(payload.size.toLong(), result.total)
|
||||||
|
assertContentEquals(payload, dest.readBytes())
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download resumes from existing part file with Range request`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val partFile = File("${dest.absolutePath}.part")
|
||||||
|
val prefixSize = 4096
|
||||||
|
partFile.writeBytes(payload.copyOfRange(0, prefixSize))
|
||||||
|
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val result = dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(prefixSize.toLong(), result.resumedFrom)
|
||||||
|
assertEquals(payload.size.toLong(), result.bytes)
|
||||||
|
assertContentEquals(payload, dest.readBytes())
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download reports progress via callback`() = runBlocking {
|
||||||
|
val fake = startFakeServer()
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
val reports = mutableListOf<Pair<Long, Long>>()
|
||||||
|
dl.download(
|
||||||
|
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
||||||
|
destPath = dest.absolutePath,
|
||||||
|
progress = { d, t -> reports += d to t },
|
||||||
|
)
|
||||||
|
assertTrue(reports.isNotEmpty(), "progress must be reported at least once")
|
||||||
|
assertEquals(payload.size.toLong(), reports.last().first)
|
||||||
|
assertEquals(payload.size.toLong(), reports.last().second)
|
||||||
|
} finally {
|
||||||
|
fake.server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `download fails with clear message on HTTP 404`() = runBlocking {
|
||||||
|
// Spin up server that always 404s
|
||||||
|
val port = ServerSocket(0).use { it.localPort }
|
||||||
|
val server = embeddedServer(CIO, port = port) {
|
||||||
|
routing {
|
||||||
|
head("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
|
||||||
|
get("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start(wait = false)
|
||||||
|
try {
|
||||||
|
val dest = tmpFile()
|
||||||
|
val dl = ModelDownloader()
|
||||||
|
try {
|
||||||
|
dl.download(url = "http://127.0.0.1:$port/missing", destPath = dest.absolutePath)
|
||||||
|
fail("expected failure on 404")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val msg = e.message ?: ""
|
||||||
|
assertTrue("404" in msg || "Not Found" in msg,
|
||||||
|
"error should mention HTTP 404, got: $msg")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop(100, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FakeServer(val server: io.ktor.server.engine.EmbeddedServer<*, *>, val port: Int)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package pw.binom.agentik.standalone.mcp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.agentik.mcp.bridge.McpConfig
|
||||||
|
import pw.binom.agentik.mcp.bridge.McpServerSpec
|
||||||
|
|
||||||
|
class McpConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv returns empty when env var unset`() {
|
||||||
|
val cfg = McpConfig.fromEnv { null }
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
assertEquals(emptyList(), cfg.servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv returns empty when env var blank`() {
|
||||||
|
val cfg = McpConfig.fromEnv { "" }
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromEnv returns empty when file missing`() {
|
||||||
|
val cfg = McpConfig.fromEnv { "/tmp/agentik-nonexistent-mcp-${System.nanoTime()}.json" }
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson parses stdio server`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"fs": { "command": "npx", "args": ["-y", "fs-mcp"], "env": { "ROOT": "/work" } }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
assertEquals(1, cfg.servers.size)
|
||||||
|
val s = cfg.servers.single() as McpServerSpec.Stdio
|
||||||
|
assertEquals("fs", s.name)
|
||||||
|
assertEquals("npx", s.command)
|
||||||
|
assertEquals(listOf("-y", "fs-mcp"), s.args)
|
||||||
|
assertEquals(mapOf("ROOT" to "/work"), s.env)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson parses http server with headers`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer X" } }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
val s = cfg.servers.single() as McpServerSpec.Http
|
||||||
|
assertEquals("remote", s.name)
|
||||||
|
assertEquals("https://example.com/mcp", s.url)
|
||||||
|
assertEquals(mapOf("Authorization" to "Bearer X"), s.headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson parses both stdio and http together`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"fs": { "command": "npx", "args": [] },
|
||||||
|
"remote":{ "url": "https://example.com/mcp" }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
assertEquals(2, cfg.servers.size)
|
||||||
|
assertTrue(cfg.servers.any { it is McpServerSpec.Stdio && it.name == "fs" })
|
||||||
|
assertTrue(cfg.servers.any { it is McpServerSpec.Http && it.name == "remote" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromJson skips entries without command or url`() {
|
||||||
|
val json = """
|
||||||
|
{ "mcpServers": {
|
||||||
|
"broken": { "description": "no transport" }
|
||||||
|
} }
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = McpConfig.fromJson(json)
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty returns empty config`() {
|
||||||
|
val cfg = McpConfig.empty()
|
||||||
|
assertTrue(cfg.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package pw.binom.agentik.standalone.mcp
|
||||||
|
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.agentik.toolsets.NamedTool
|
||||||
|
import pw.binom.agentik.mcp.bridge.McpConfig
|
||||||
|
import pw.binom.agentik.mcp.bridge.McpRegistry
|
||||||
|
|
||||||
|
class McpRegistryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty config produces empty registry`() {
|
||||||
|
val registry = McpRegistry.fromConfig(McpConfig.empty())
|
||||||
|
assertEquals(0, registry.allTools.size)
|
||||||
|
assertEquals(0, registry.connectedServerCount)
|
||||||
|
assertEquals(emptyList(), registry.namedTools)
|
||||||
|
registry.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty servers list produces empty registry`() {
|
||||||
|
val registry = McpRegistry(servers = emptyList())
|
||||||
|
assertEquals(0, registry.namedTools.size)
|
||||||
|
assertEquals(0, registry.allTools.size)
|
||||||
|
registry.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NamedTool holds name and tool reference`() {
|
||||||
|
val noop: LiteTool = object : LiteTool {
|
||||||
|
override fun describe(): String = "{}"
|
||||||
|
override fun invoke(arguments: String): String = ""
|
||||||
|
}
|
||||||
|
val nt = NamedTool(name = "server__echo", tool = noop)
|
||||||
|
assertEquals("server__echo", nt.name)
|
||||||
|
assertSame(noop, nt.tool)
|
||||||
|
assertEquals("{}", nt.tool.describe())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `registry close is idempotent`() {
|
||||||
|
val registry = McpRegistry.fromConfig(McpConfig.empty())
|
||||||
|
registry.close()
|
||||||
|
registry.close() // should not throw
|
||||||
|
assertTrue(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
+372
@@ -0,0 +1,372 @@
|
|||||||
|
package pw.binom.agentik.standalone.persistence
|
||||||
|
import pw.binom.agentik.journal.MessageContext
|
||||||
|
import pw.binom.agentik.journal.MessageOrigin
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.Content
|
||||||
|
import pw.binom.agentik.context.WorkingMemoryEntry
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class PersistenceTest {
|
||||||
|
|
||||||
|
private lateinit var stores: KsqliteStores
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
stores.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `upsert + get conversation - roundtrip`() = runTest {
|
||||||
|
val now = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val rec = ConversationRecord(
|
||||||
|
id = "c1",
|
||||||
|
title = "Hello",
|
||||||
|
isTemporal = false,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
stores.conversations.upsert(rec)
|
||||||
|
val got = stores.conversations.get("c1")
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals(rec.id, got.id)
|
||||||
|
assertEquals(rec.title, got.title)
|
||||||
|
assertEquals(rec.isTemporal, got.isTemporal)
|
||||||
|
assertEquals(rec.createdAt, got.createdAt)
|
||||||
|
assertEquals(rec.updatedAt, got.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `upsert overwrites existing record`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord("c1", title = "A", isTemporal = false, createdAt = t0, updatedAt = t0),
|
||||||
|
)
|
||||||
|
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord("c1", title = "B", isTemporal = true, createdAt = t0, updatedAt = t1),
|
||||||
|
)
|
||||||
|
val got = stores.conversations.get("c1")!!
|
||||||
|
assertEquals("B", got.title)
|
||||||
|
assertTrue(got.isTemporal)
|
||||||
|
assertEquals(t1, got.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list returns conversations ordered by updated_at desc`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
repeat(3) { i ->
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord(
|
||||||
|
id = "c$i",
|
||||||
|
title = null,
|
||||||
|
isTemporal = false,
|
||||||
|
createdAt = t0,
|
||||||
|
updatedAt = Instant.fromEpochMilliseconds(1_700_000_000_000 + i * 1000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val list = stores.conversations.list(offset = 0, limit = 10)
|
||||||
|
assertEquals(listOf("c2", "c1", "c0"), list.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete cascades messages and working_memory`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(
|
||||||
|
ConversationRecord("c1", null, false, t0, t0),
|
||||||
|
)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "c1",
|
||||||
|
content = listOf(Content.Text("hello")),
|
||||||
|
createdAt = t0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.User(
|
||||||
|
sourceMessageId = "m1",
|
||||||
|
content = listOf(Content.Text("hello")),
|
||||||
|
),
|
||||||
|
now = t0,
|
||||||
|
)
|
||||||
|
assertEquals(1, stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList().size)
|
||||||
|
assertEquals(1, stores.workingMemory.list("c1").size)
|
||||||
|
|
||||||
|
val removed = stores.conversations.delete("c1")
|
||||||
|
assertTrue(removed)
|
||||||
|
assertNull(stores.conversations.get("c1"))
|
||||||
|
assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList())
|
||||||
|
assertEquals(emptyList(), stores.workingMemory.list("c1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `message audit log - append and read back`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0),
|
||||||
|
)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(2, all.size)
|
||||||
|
assertEquals("m1", all[0].id)
|
||||||
|
assertEquals("m2", all[1].id)
|
||||||
|
assertTrue(all[0] is MessageRecord.UserMessage)
|
||||||
|
assertTrue(all[1] is MessageRecord.AssistantMessage)
|
||||||
|
assertEquals("hi", (all[0] as MessageRecord.UserMessage).content[0].let {
|
||||||
|
(it as Content.Text).body
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `message after timestamp filter`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||||
|
stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0))
|
||||||
|
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1))
|
||||||
|
|
||||||
|
val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10)
|
||||||
|
assertEquals(1, after.size)
|
||||||
|
assertEquals("m2", after[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working memory - append + list preserves order`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))),
|
||||||
|
now = t0,
|
||||||
|
)
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.Assistant(sourceMessageId = "m2", content = listOf(Content.Text("yo"))),
|
||||||
|
now = t1,
|
||||||
|
)
|
||||||
|
val list = stores.workingMemory.list("c1")
|
||||||
|
assertEquals(2, list.size)
|
||||||
|
assertTrue(list[0].entry is WorkingMemoryEntry.User)
|
||||||
|
assertTrue(list[1].entry is WorkingMemoryEntry.Assistant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working memory - compact without summary just drops tail`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
||||||
|
val rows = stores.workingMemory.list("c1")
|
||||||
|
// Drop начиная со второго хода (User m1) — должно остаться только User m1.
|
||||||
|
val dropFrom = rows[1].orderIdx
|
||||||
|
stores.workingMemory.compact(dropFrom, "c1", summaryText = null)
|
||||||
|
val after = stores.workingMemory.list("c1")
|
||||||
|
assertEquals(1, after.size)
|
||||||
|
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working memory - compact with summary inserts Summary entry`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
||||||
|
val rows = stores.workingMemory.list("c1")
|
||||||
|
val dropFrom = rows[1].orderIdx
|
||||||
|
stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1")
|
||||||
|
val after = stores.workingMemory.list("c1")
|
||||||
|
assertEquals(2, after.size)
|
||||||
|
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
||||||
|
val summary = after[1].entry
|
||||||
|
assertIs<WorkingMemoryEntry.Summary>(summary)
|
||||||
|
assertTrue(summary.text.startsWith("**Goal**"))
|
||||||
|
// order_idx должен быть > всех оставшихся
|
||||||
|
assertTrue(after[1].orderIdx > after[0].orderIdx)
|
||||||
|
// sourceMessageId у Summary всегда null
|
||||||
|
assertNull(after[1].sourceMessageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working memory - compact with blank summaryText behaves as drop`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
|
val rows = stores.workingMemory.list("c1")
|
||||||
|
stores.workingMemory.compact(rows[0].orderIdx + 1, "c1", summaryText = "")
|
||||||
|
val after = stores.workingMemory.list("c1")
|
||||||
|
assertEquals(1, after.size)
|
||||||
|
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working memory - compact is atomic on other conversations`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
||||||
|
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
||||||
|
stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0)
|
||||||
|
stores.workingMemory.append("c2", WorkingMemoryEntry.Assistant("m3", listOf(Content.Text("a2"))), t0)
|
||||||
|
stores.workingMemory.compact(2, "c1", summaryText = "sum")
|
||||||
|
val c1 = stores.workingMemory.list("c1")
|
||||||
|
val c2 = stores.workingMemory.list("c2")
|
||||||
|
// c1: User m1 + Summary
|
||||||
|
assertEquals(2, c1.size)
|
||||||
|
assertTrue(c1[1].entry is WorkingMemoryEntry.Summary)
|
||||||
|
// c2 не тронут
|
||||||
|
assertEquals(2, c2.size)
|
||||||
|
assertTrue(c2[1].entry is WorkingMemoryEntry.Assistant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename updates title and bumps updated_at`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(ConversationRecord("c1", null, false, t0, t0))
|
||||||
|
val newTs = stores.conversations.rename("c1", "Renamed")
|
||||||
|
assertNotNull(newTs)
|
||||||
|
assertTrue(newTs > t0)
|
||||||
|
val got = stores.conversations.get("c1")!!
|
||||||
|
assertEquals("Renamed", got.title)
|
||||||
|
assertEquals(newTs, got.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename can clear title`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.conversations.upsert(ConversationRecord("c1", "Title", false, t0, t0))
|
||||||
|
stores.conversations.rename("c1", null)
|
||||||
|
val got = stores.conversations.get("c1")!!
|
||||||
|
assertNull(got.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `delete returns false when conversation does not exist`() = runTest {
|
||||||
|
assertFalse(stores.conversations.delete("nope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `error record roundtrip through audit log`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.Error(
|
||||||
|
id = "e1",
|
||||||
|
conversationId = "c1",
|
||||||
|
message = "boom",
|
||||||
|
code = "E42",
|
||||||
|
createdAt = t0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.Error(
|
||||||
|
id = "e2",
|
||||||
|
conversationId = "c1",
|
||||||
|
message = "no code",
|
||||||
|
code = null,
|
||||||
|
createdAt = Instant.fromEpochMilliseconds(1_700_000_001_000),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(2, all.size)
|
||||||
|
val first = assertIs<MessageRecord.Error>(all[0])
|
||||||
|
assertEquals("e1", first.id)
|
||||||
|
assertEquals("boom", first.message)
|
||||||
|
assertEquals("E42", first.code)
|
||||||
|
assertEquals(t0, first.createdAt)
|
||||||
|
assertNull(assertIs<MessageRecord.Error>(all[1]).code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `image content roundtrip through message payload`() = runTest { val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val bytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "c1",
|
||||||
|
content = listOf(Content.Image(data = bytes, mime = "image/png")),
|
||||||
|
createdAt = t0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
||||||
|
val image = (all[0] as MessageRecord.UserMessage).content[0] as Content.Image
|
||||||
|
assertEquals("image/png", image.mime)
|
||||||
|
assertTrue(bytes.contentEquals(image.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `user message context roundtrips through SQLite`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val ctx = MessageContext(
|
||||||
|
origin = MessageOrigin.EVENT,
|
||||||
|
description = "scheduled cron morning-briefing",
|
||||||
|
sourceId = "cron-42",
|
||||||
|
)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "c1",
|
||||||
|
content = listOf(Content.Text("wake up")),
|
||||||
|
createdAt = t0,
|
||||||
|
context = ctx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
||||||
|
assertEquals(1, all.size)
|
||||||
|
val user = assertIs<MessageRecord.UserMessage>(all[0])
|
||||||
|
assertEquals(ctx, user.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `user message without context roundtrips with null context`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
stores.messages.append(
|
||||||
|
MessageRecord.UserMessage(
|
||||||
|
id = "m1",
|
||||||
|
conversationId = "c1",
|
||||||
|
content = listOf(Content.Text("regular user message")),
|
||||||
|
createdAt = t0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
||||||
|
val user = assertIs<MessageRecord.UserMessage>(all[0])
|
||||||
|
assertNull(user.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working memory user entry context roundtrips through SQLite`() = runTest {
|
||||||
|
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||||
|
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "agent startup")
|
||||||
|
stores.workingMemory.append(
|
||||||
|
conversationId = "c1",
|
||||||
|
entry = WorkingMemoryEntry.User(
|
||||||
|
sourceMessageId = "m1",
|
||||||
|
content = listOf(Content.Text("boot")),
|
||||||
|
context = ctx,
|
||||||
|
),
|
||||||
|
now = t0,
|
||||||
|
)
|
||||||
|
val list = stores.workingMemory.list("c1")
|
||||||
|
assertEquals(1, list.size)
|
||||||
|
val user = assertIs<WorkingMemoryEntry.User>(list[0].entry)
|
||||||
|
assertEquals(ctx, user.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
|
api(project(":journal-inmemory"))
|
||||||
api(project(":reflection-api"))
|
api(project(":reflection-api"))
|
||||||
api(project(":context-api"))
|
api(project(":context-api"))
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -2,7 +2,8 @@ package pw.binom.agentik.storage.inmemory
|
|||||||
|
|
||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.journal.MutableJournalStore
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
import pw.binom.agentik.journal.ConversationStore
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||||
import pw.binom.agentik.reflection.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@@ -21,14 +22,14 @@ import kotlin.time.Clock
|
|||||||
*/
|
*/
|
||||||
object InMemoryStorage {
|
object InMemoryStorage {
|
||||||
data class Bundle(
|
data class Bundle(
|
||||||
val conversationStore: ConversationStore,
|
val conversationStore: MutableConversationStore,
|
||||||
val messageStore: MutableJournalStore,
|
val messageStore: MutableJournalStore,
|
||||||
val workingMemoryStore: ContextStore,
|
val workingMemoryStore: ContextStore,
|
||||||
val reflectionStore: ReflectionStore,
|
val reflectionStore: ReflectionStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun create(clock: Clock = Clock.System): Bundle = Bundle(
|
fun create(clock: Clock = Clock.System): Bundle = Bundle(
|
||||||
conversationStore = InMemoryConversationStore(clock),
|
conversationStore = InMemoryMutableConversationStore(clock),
|
||||||
messageStore = InMemoryMessageStore(),
|
messageStore = InMemoryMessageStore(),
|
||||||
workingMemoryStore = InMemoryWorkingMemoryStore(),
|
workingMemoryStore = InMemoryWorkingMemoryStore(),
|
||||||
reflectionStore = InMemoryReflectionStore(),
|
reflectionStore = InMemoryReflectionStore(),
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ plugins {
|
|||||||
//
|
//
|
||||||
// Apple targets (macos*, ios*, tvos*, watchos*) невозможно собрать на Linux —
|
// Apple targets (macos*, ios*, tvos*, watchos*) невозможно собрать на Linux —
|
||||||
// Kotlin Multiplatform plugin auto-disables их, так что даже не пытаемся.
|
// Kotlin Multiplatform plugin auto-disables их, так что даже не пытаемся.
|
||||||
|
//
|
||||||
|
// ksqlite 0.1.2 опубликован в Maven Central (см. settings.gradle.kts —
|
||||||
|
// mavenCentral() уже в списке репозиториев), так что CI его видит.
|
||||||
|
// Предыдущая зависимость `pw.binom.db:ksqlite:0.1.1-SNAPSHOT` жила только
|
||||||
|
// в локальном ~/.m2 и ломала CI.
|
||||||
|
// text-embedding-kmp с v4 (2026-09-21) опубликован в caffeine Nexus.
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
@@ -30,9 +36,9 @@ kotlin {
|
|||||||
// targets (macosX64/Arm64, iosX64/Arm64/SimulatorArm64, linuxArm64),
|
// targets (macosX64/Arm64, iosX64/Arm64/SimulatorArm64, linuxArm64),
|
||||||
// так что KMP-метаданные корректно резолвятся для всех таргетов.
|
// так что KMP-метаданные корректно резолвятся для всех таргетов.
|
||||||
|
|
||||||
// ksqlite ещё не опубликован в Maven Central — только в локальном
|
// ksqlite 0.1.2 опубликован в Maven Central — обычный
|
||||||
// caffeine-репо. Версия пока 0.1.0-SNAPSHOT (CI fallback из README).
|
// `mavenCentral()` в settings.gradle.kts его подтянет.
|
||||||
implementation("pw.binom.db:ksqlite:0.1.1-SNAPSHOT")
|
implementation("pw.binom.db:ksqlite:0.1.2")
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
api(project(":journal-api"))
|
api(project(":journal-api"))
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@ package pw.binom.agentik.storage.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,15 @@ 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_*`.
|
||||||
*/
|
*/
|
||||||
class KsqliteConversationStore(
|
class KsqliteMutableConversationStore(
|
||||||
private val connection: SQLiteConnection,
|
private val connection: SQLiteConnection,
|
||||||
private val messageStore: KsqliteMessageStore? = null,
|
private val messageStore: KsqliteMessageStore? = null,
|
||||||
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
|
private val workingMemoryStore: KsqliteWorkingMemoryStore? = null,
|
||||||
) : ConversationStore {
|
) : MutableConversationStore {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
+3
-3
@@ -2,7 +2,7 @@ package pw.binom.agentik.storage.ksqlite
|
|||||||
|
|
||||||
import pw.binom.agentik.context.ContextStore
|
import pw.binom.agentik.context.ContextStore
|
||||||
import pw.binom.agentik.journal.MutableJournalStore
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
import pw.binom.agentik.journal.ConversationStore
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
import pw.binom.agentik.reflection.ReflectionStore
|
import pw.binom.agentik.reflection.ReflectionStore
|
||||||
import pw.binom.db.ksqlite.SQLiteConnection
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ import pw.binom.db.ksqlite.SQLiteConnection
|
|||||||
*/
|
*/
|
||||||
class KsqliteStores internal constructor(
|
class KsqliteStores internal constructor(
|
||||||
val connection: SQLiteConnection,
|
val connection: SQLiteConnection,
|
||||||
val conversations: ConversationStore,
|
val conversations: MutableConversationStore,
|
||||||
val messages: MutableJournalStore,
|
val messages: MutableJournalStore,
|
||||||
val workingMemory: ContextStore,
|
val workingMemory: ContextStore,
|
||||||
val reflections: ReflectionStore,
|
val reflections: ReflectionStore,
|
||||||
@@ -51,7 +51,7 @@ class KsqliteStores internal constructor(
|
|||||||
val working = KsqliteWorkingMemoryStore(conn)
|
val working = KsqliteWorkingMemoryStore(conn)
|
||||||
return KsqliteStores(
|
return KsqliteStores(
|
||||||
connection = conn,
|
connection = conn,
|
||||||
conversations = KsqliteConversationStore(conn, messages, working),
|
conversations = KsqliteMutableConversationStore(conn, messages, working),
|
||||||
messages = messages,
|
messages = messages,
|
||||||
workingMemory = working,
|
workingMemory = working,
|
||||||
reflections = KsqliteReflectionStore(conn),
|
reflections = KsqliteReflectionStore(conn),
|
||||||
|
|||||||
+13
-3
@@ -30,7 +30,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 +59,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 +72,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?)
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import kotlin.test.assertTrue
|
|||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class KsqliteConversationStoreTest {
|
class KsqliteMutableConversationStoreTest {
|
||||||
private lateinit var stores: KsqliteStores
|
private lateinit var stores: KsqliteStores
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
+1
-1
@@ -79,7 +79,7 @@ class SchemaMigrationTest {
|
|||||||
// constructor internal, тест в том же модуле и может его звать.
|
// constructor internal, тест в том же модуле и может его звать.
|
||||||
val stores = KsqliteStores(
|
val stores = KsqliteStores(
|
||||||
connection = conn,
|
connection = conn,
|
||||||
conversations = KsqliteConversationStore(conn),
|
conversations = KsqliteMutableConversationStore(conn),
|
||||||
messages = KsqliteMessageStore(conn),
|
messages = KsqliteMessageStore(conn),
|
||||||
workingMemory = KsqliteWorkingMemoryStore(conn),
|
workingMemory = KsqliteWorkingMemoryStore(conn),
|
||||||
reflections = KsqliteReflectionStore(conn),
|
reflections = KsqliteReflectionStore(conn),
|
||||||
|
|||||||
Reference in New Issue
Block a user