Compare commits
7 Commits
7
...
c0a933d251
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a933d251 | |||
| 29851c047a | |||
| c9995b263e | |||
| 7865eed836 | |||
| 0a7c40688c | |||
| 161be41adf | |||
| 68543357c2 |
@@ -5,6 +5,9 @@
|
||||
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
|
||||
# Все env secrets доступны через vars/secrets репозитория — см. начало
|
||||
# release.yml для требуемых переменных.
|
||||
#
|
||||
# :agentik-cli / :agentik-tui исключены из сборки (settings.gradle.kts
|
||||
# 2026-09-17/21) — соответствующие шаги shadowJar тут НЕ запускаются.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
@@ -67,15 +70,10 @@ jobs:
|
||||
test -f standalone/build/libs/standalone-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
|
||||
|
||||
- name: Build :agentik-cli shadowJar
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew :agentik-cli:shadowJar \
|
||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||
--no-daemon --no-watch-fs --stacktrace
|
||||
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
|
||||
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
||||
|
||||
# Шага "Build :agentik-cli shadowJar" здесь нет: :agentik-cli исключён из
|
||||
# settings.gradle.kts (2026-09-21). :agentik-tui — тоже исключён (2026-09-17).
|
||||
# Когда/если оба вернутся, добавим отдельные шаги по аналогии с :standalone.
|
||||
#
|
||||
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
|
||||
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
|
||||
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
|
||||
|
||||
@@ -18,6 +18,8 @@ out/
|
||||
|
||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||
.cortexkit/
|
||||
# opencode CLI local config (per-machine, не коммитим)
|
||||
config.json
|
||||
.veai/
|
||||
|
||||
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
|
||||
|
||||
@@ -27,9 +27,10 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
||||
// Подписываемся на поток событий ДО send: события, отправленные
|
||||
// до подписки, не реплеятся (shared-flow без replay).
|
||||
val eventsJob = launch {
|
||||
conv.events(Instant.DISTANT_PAST)
|
||||
agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||
// onEach печатает и терминальный event, takeWhile лишь
|
||||
// завершает сбор после него.
|
||||
.map { it.event }
|
||||
.onEach { ev -> emit(ev) }
|
||||
.takeWhile { ev -> !isTerminal(ev) }
|
||||
.collect { }
|
||||
@@ -53,7 +54,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход
|
||||
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
|
||||
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
|
||||
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
|
||||
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
|
||||
is Event.ToolResult -> println("event ToolResult ${ev.toolCallId} ${escape(ev.result ?: "")}")
|
||||
is Event.End -> println("event End")
|
||||
is Event.Interrupted -> println("event Interrupted")
|
||||
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
|
||||
|
||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.launch
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -92,12 +92,12 @@ internal class TuiBackend(
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывается на [Conversation.events] и перенаправляет их в [state].
|
||||
* Подписывается на `outbox.conversationEvents(after, conv.id)` и перенаправляет их в [state].
|
||||
*/
|
||||
private fun subscribeEvents(conv: Conversation, from: Instant) {
|
||||
eventsJob?.cancel()
|
||||
eventsJob = scope.launch {
|
||||
conv.events(from).collect { ev -> dispatch(ev) }
|
||||
agent.outbox.conversationEvents(from, conv.id).collect { ce -> dispatch(ce.event) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.Agent
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import kotlin.time.Instant
|
||||
@@ -31,7 +31,7 @@ internal class FakeAgent(
|
||||
// emptyFlow, journal — error-on-access (никто не должен его трогать).
|
||||
override val journal: JournalStore = error("journal not used in TuiBackend tests")
|
||||
override val outbox: OutboxStore = object : OutboxStore {
|
||||
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.proto.CommonEvent>()
|
||||
override fun events(after: Instant?) = emptyFlow<pw.binom.agentik.outbox.CommonEvent>()
|
||||
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.outbox.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
@@ -170,7 +170,7 @@ class TuiBackendTest {
|
||||
runCurrent()
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
|
||||
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
|
||||
conv.emit(Event.ToolResult(date = now, toolCallId = "1", result = "ok"))
|
||||
runCurrent()
|
||||
|
||||
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
|
||||
|
||||
+72
-1
@@ -16,6 +16,11 @@
|
||||
- `HttpJournalStore` — `list(convId, after, offset, limit)` → `List<MessageRecord>`
|
||||
со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens).
|
||||
- `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE).
|
||||
- `ReconnectingOutbox(outbox, scope, policy)` — обёртка над `OutboxStore` с
|
||||
авто-reconnect при обрыве стрима (exponential backoff). Два независимых
|
||||
потока: `events()` (те же `CommonEvent`) и `connectionStatus()`
|
||||
(`Connecting`/`Connected`/`Disconnected`/`Failed`) — статус НЕ мешается
|
||||
с основным потоком событий. См. ниже.
|
||||
|
||||
`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно
|
||||
вручную создавать `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 минут
|
||||
|
||||
Один self-contained пример: создаём агента, открываем диалог,
|
||||
@@ -322,7 +351,49 @@ UI-обновление списка — отдельная задача, реш
|
||||
```
|
||||
|
||||
Покрывают: 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)`.
|
||||
|
||||
## Известное ограничение
|
||||
|
||||
|
||||
@@ -20,12 +20,43 @@ import pw.binom.agentik.proto.Agent
|
||||
* )
|
||||
* val conv = agent.createConversation(temp = false)
|
||||
* conv.send(listOf(Content.Text("hi")))
|
||||
* conv.events(Instant.DISTANT_PAST).collect { ... }
|
||||
* agent.outbox.conversationEvents(Instant.DISTANT_PAST, conv.id)
|
||||
* .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 на сервере.
|
||||
*
|
||||
* **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает
|
||||
* HttpClient (идемпотентно). После этого `createConversation` /
|
||||
|
||||
@@ -6,18 +6,12 @@ import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.agentik.proto.Content
|
||||
import pw.binom.agentik.proto.Conversation
|
||||
import pw.binom.agentik.proto.Event
|
||||
import pw.binom.agentik.proto.Message
|
||||
import pw.binom.agentik.proto.MessageContext
|
||||
import kotlin.time.Instant
|
||||
@@ -72,22 +66,6 @@ internal class ConversationClient(
|
||||
httpClient.post("$convUrl/interrupt")
|
||||
}
|
||||
|
||||
override fun events(after: Instant): Flow<Event> = flow {
|
||||
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
|
||||
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
|
||||
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
|
||||
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
|
||||
.execute { response ->
|
||||
check(response.status == HttpStatusCode.OK) {
|
||||
"events: server returned ${response.status}"
|
||||
}
|
||||
readSse(response.bodyAsChannel())
|
||||
.collect { payload ->
|
||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
|
||||
httpClient.get("$convUrl/messages") {
|
||||
parameter("after", after.toString())
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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.ExperimentalAtomicApi
|
||||
import kotlin.math.min
|
||||
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
|
||||
|
||||
@Volatile
|
||||
private var lastSeen: Instant? = 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 = initialCursor
|
||||
job = scope.launch { runLoop() }
|
||||
}
|
||||
|
||||
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).collect { event ->
|
||||
lastSeen = event.date
|
||||
_events.emit(event)
|
||||
if (!connected) {
|
||||
connected = true
|
||||
_status.emit(ConnectionStatus.Connected(lastSeen!!))
|
||||
}
|
||||
}
|
||||
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() *
|
||||
Math.pow(policy.multiplier, (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 {
|
||||
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)
|
||||
|
||||
api(project(":context-api"))
|
||||
|
||||
@@ -10,7 +10,7 @@ litert = "8"
|
||||
sqldelight = "2.3.2"
|
||||
shadow = "8.3.5"
|
||||
jvector = "3.0.6"
|
||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
||||
text-embedding-kmp = "5"
|
||||
kotlin-logging = "3.0.5"
|
||||
logback = "1.5.18"
|
||||
mosaic = "0.18.0"
|
||||
@@ -97,8 +97,15 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
|
||||
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
|
||||
|
||||
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
|
||||
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
|
||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
|
||||
# `api` — KMP с jvm + android + linuxX64/Arm64 + macos + ios + mingwX64
|
||||
# (с 2026-09-21, когда мы добавили нативные цели в text-embedding-kmp:api).
|
||||
# Версия 5 — первый релиз с реальными нативными klib-вариантами в caffeine
|
||||
# (v4 имел только jvm+android, что ломало native-resolve в :memory-md-vector).
|
||||
# Используется из :memory-md-vector и :memory-vector напрямую через
|
||||
# `libs.text.embedding.api` (без суффикса `-jvm` — Gradle сам выберет
|
||||
# нужный variant под target).
|
||||
# `siglip` — JVM+Android only (onnx-runtime), подключается в jvmMain.
|
||||
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api", version.ref = "text-embedding-kmp" }
|
||||
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
|
||||
|
||||
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
|
||||
|
||||
@@ -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,27 @@
|
||||
package pw.binom.agentik.journal
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* CRUD по таблице `conversation`.
|
||||
*/
|
||||
interface ConversationStore : AutoCloseable {
|
||||
|
||||
/** Создать или обновить snapshot диалога. */
|
||||
suspend fun upsert(record: ConversationRecord)
|
||||
|
||||
/** Диалог по id, или `null`. */
|
||||
suspend fun get(id: String): ConversationRecord?
|
||||
|
||||
/** Удалить диалог (вместе с его сообщениями и working memory). */
|
||||
suspend fun delete(id: String): Boolean
|
||||
|
||||
/** Список диалогов, отсортированный по `updatedAt` DESC. */
|
||||
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
|
||||
|
||||
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
|
||||
suspend fun rename(id: String, title: String?): Instant?
|
||||
|
||||
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
|
||||
suspend fun touch(id: String, now: Instant)
|
||||
}
|
||||
@@ -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 conversationId: String,
|
||||
val toolCallId: String,
|
||||
/**
|
||||
* Имя тула, денормализованное из соответствующего `MessageRecord.ToolCall.toolName`.
|
||||
* Денормализация экономна (одна строка в SQLite) и снимает с UI
|
||||
* необходимость сопоставления `toolCallId → toolName`. `null` —
|
||||
* безопасный backfill для записей до миграции или для сиротливых
|
||||
* результатов без предшествующего `ToolCall`.
|
||||
*/
|
||||
val toolName: String? = null,
|
||||
val result: String?,
|
||||
override val createdAt: Instant,
|
||||
) : MessageRecord
|
||||
|
||||
@@ -20,7 +20,9 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
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)
|
||||
|
||||
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(
|
||||
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(
|
||||
ErrorPayload.serializer(),
|
||||
@@ -59,7 +59,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
}
|
||||
"tool_result" -> {
|
||||
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" -> {
|
||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||
@@ -72,8 +72,18 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
|
||||
|
||||
/**
|
||||
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
|
||||
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
|
||||
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
|
||||
* без поля десериализуются как `null`.
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ResultPayload(val toolCallId: String, val result: String?)
|
||||
internal data class ResultPayload(
|
||||
val toolCallId: String,
|
||||
val toolName: String? = null,
|
||||
val result: String?,
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ErrorPayload(val message: String, val code: String?)
|
||||
|
||||
@@ -21,6 +21,13 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
// `TextEmbeddingExecutor` (suspend-обёртка над `TextEmbeddingExtractor`)
|
||||
// живёт в :memory-api с 2026-09-21 — раньше был `EmbeddingProvider` в
|
||||
// :memory-vector, но он JVM-only и блокировал :memory-md-vector от
|
||||
// нативных таргетов. text-embedding-kmp:api собирается под jvm+android+
|
||||
// linux/macos/ios/mingw (мы добавили нативные цели в их :api модуле),
|
||||
// так что KMP-потребители могут зависеть от него напрямую.
|
||||
api(libs.text.embedding.api)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
|
||||
@@ -24,4 +24,11 @@ data class MemoryNote(
|
||||
val useCount: Int = 0,
|
||||
val conversationId: String? = null,
|
||||
val source: MemorySource,
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Дешёвый content-fingerprint: хэш от id + content.
|
||||
* Используется vector-кэшами (`:memory-md-vector`, `:memory-vector`) для
|
||||
* определения "изменилась ли заметка" без re-embed'а.
|
||||
*/
|
||||
fun contentHash(): String = (id.hashCode().toLong() xor content.hashCode().toLong()).toString(16)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
|
||||
/**
|
||||
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score
|
||||
* в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||
*
|
||||
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||
* `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит
|
||||
* от JVM-only `:memory-vector`.
|
||||
*/
|
||||
data class ScoredVector(
|
||||
val id: String,
|
||||
val score: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||
* векторов к query. Метаданные заметок лежат в `MemoryStore` (для
|
||||
* vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`);
|
||||
* индекс хранит только embedding'и + id-маппинг.
|
||||
*
|
||||
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||
* `:memory-md-vector` (KMP).
|
||||
*
|
||||
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации —
|
||||
* это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||
*/
|
||||
interface MemoryVectorIndex : AutoCloseable {
|
||||
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||
val dimension: Int
|
||||
|
||||
/** Количество записей в индексе. */
|
||||
suspend fun size(): Long
|
||||
|
||||
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
|
||||
suspend fun add(id: String, embedding: FloatArray)
|
||||
|
||||
/** Удалить запись по [id]. Возвращает true если запись была. */
|
||||
suspend fun remove(id: String): Boolean
|
||||
|
||||
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id. */
|
||||
suspend fun search(
|
||||
query: FloatArray,
|
||||
k: Int,
|
||||
filter: (MemoryNote) -> Boolean = { true },
|
||||
): List<ScoredVector>
|
||||
|
||||
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
|
||||
suspend fun flush()
|
||||
|
||||
override fun close()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
/**
|
||||
* Доп. контекст для vector-индекса: фильтр по категории и conversationId.
|
||||
*
|
||||
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с
|
||||
* переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён
|
||||
* сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`.
|
||||
*
|
||||
* Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые
|
||||
* будущие) должны использовать этот хелпер при фильтрации результатов search,
|
||||
* чтобы контракт был единый.
|
||||
*/
|
||||
fun noteMatches(
|
||||
note: MemoryNote,
|
||||
category: MemoryCategory? = null,
|
||||
conversationId: String? = null,
|
||||
): Boolean {
|
||||
if (category != null && note.category != category) return false
|
||||
if (conversationId != null && note.conversationId != conversationId) return false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
|
||||
/**
|
||||
* Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`.
|
||||
*
|
||||
* `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP),
|
||||
* поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают
|
||||
* честный suspend, а блокирующая работа уходит в background dispatcher.
|
||||
*
|
||||
* Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI
|
||||
* text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем
|
||||
* его; иначе — определяем лениво по первому [embed] (probe-vector на пустом
|
||||
* тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования,
|
||||
* так что для prod-использования рекомендуется всегда передавать [knownDimension]
|
||||
* явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а).
|
||||
*
|
||||
* @param extractor underlying extractor (не null)
|
||||
* @param knownDimension заранее известная размерность; null = определить по probe
|
||||
*/
|
||||
class TextEmbeddingExecutor(
|
||||
val extractor: TextEmbeddingExtractor,
|
||||
val knownDimension: Int? = null,
|
||||
) : AutoCloseable {
|
||||
|
||||
/** Размерность векторов. Эффективно константа после первого обращения. */
|
||||
val dimension: Int by lazy {
|
||||
knownDimension ?: extractor.embed("").dim
|
||||
}
|
||||
|
||||
/**
|
||||
* Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит
|
||||
* в [Dispatchers.Default] — caller может безопасно await'ить.
|
||||
*/
|
||||
suspend fun embed(text: String): FloatArray =
|
||||
withContext(Dispatchers.Default) { extractor.embed(text).values }
|
||||
|
||||
/** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */
|
||||
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
||||
texts.map { embed(it) }
|
||||
|
||||
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
|
||||
override fun close() {
|
||||
extractor.close()
|
||||
}
|
||||
}
|
||||
@@ -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 runner'а — text-embedding-kmp ещё не
|
||||
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
|
||||
// не подключаются. Раньше был нужен потому что text-embedding-kmp был
|
||||
// только в локальном ~/.m2; с 2026-09-21 (v4 в caffeine) можно убрать,
|
||||
// но оставлен на случай если CI внезапно отвалится от Nexus.
|
||||
// Использование:
|
||||
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
|
||||
// Локальная разработка без флага — зависимости подключаются как обычно.
|
||||
@@ -25,6 +26,10 @@ kotlin {
|
||||
api(project(":memory-api"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
// `pw.binom.ai.embeddingtext:api` (TextEmbeddingExtractor + TextEmbedding)
|
||||
// теперь KMP с нативом (linuxX64/mingwX64/macOS/ios); тянем в commonMain.
|
||||
// Реализации (`siglip`, `http`) — JVM+Android only, см. jvmMain ниже.
|
||||
api(libs.text.embedding.api)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
@@ -34,14 +39,11 @@ kotlin {
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.jvector)
|
||||
implementation(libs.sqldelight.sqlite.driver)
|
||||
// Конкретная реализация TextEmbeddingExtractor поверх ONNX.
|
||||
implementation(libs.text.embedding.siglip)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
add("jvmMainApi", libs.text.embedding.api)
|
||||
add("jvmMainImplementation", libs.text.embedding.siglip)
|
||||
}
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package pw.binom.agentik.memory.vector
|
||||
|
||||
/**
|
||||
* Провайдер эмбеддингов: превращает текст в FloatArray фиксированной размерности.
|
||||
*
|
||||
* Реализация по умолчанию — HTTP-вызов `POST /v1/embeddings` к OpenAI-совместимому
|
||||
* API (OpenAI / litellm-proxy / vllm). С LRU-кэшом, чтобы не ходить в сеть
|
||||
* на каждый search/upsert.
|
||||
*/
|
||||
interface EmbeddingProvider {
|
||||
val dimension: Int
|
||||
suspend fun embed(text: String): FloatArray
|
||||
|
||||
/** Batch-вариант. По умолчанию — последовательный вызов [embed]. */
|
||||
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
||||
texts.map { embed(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Детерминированный провайдер для тестов: хеширует текст в псевдо-вектор.
|
||||
* Используется только в commonTest; в продакшн заменяется на HttpEmbeddingProvider.
|
||||
*/
|
||||
class FakeEmbeddingProvider(override val dimension: Int = 32) : EmbeddingProvider {
|
||||
override suspend fun embed(text: String): FloatArray {
|
||||
val v = FloatArray(dimension)
|
||||
// Простейший детерминированный seed — сумма char'ов по модулю.
|
||||
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
|
||||
for (i in 0 until dimension) {
|
||||
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
|
||||
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
|
||||
}
|
||||
// L2-normalize чтобы cosine работал осмысленно.
|
||||
var norm = 0f
|
||||
for (x in v) norm += x * x
|
||||
norm = kotlin.math.sqrt(norm)
|
||||
if (norm > 0f) for (i in v.indices) v[i] /= norm
|
||||
return v
|
||||
}
|
||||
}
|
||||
+26
-55
@@ -1,63 +1,34 @@
|
||||
package pw.binom.agentik.memory.vector
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
import pw.binom.agentik.memory.MemoryVectorIndex as KmpMemoryVectorIndex
|
||||
import pw.binom.agentik.memory.ScoredVector as KmpScoredVector
|
||||
|
||||
/**
|
||||
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1].
|
||||
* Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||
*/
|
||||
data class ScoredVector(
|
||||
val id: String,
|
||||
val score: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
|
||||
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
|
||||
* JVM-only alias на KMP-контракт из `:memory-api`. Удалять нельзя — пока
|
||||
* `:memory-vector` существует как JVM-only модуль с JVector-имплементацией,
|
||||
* все его internal helper'ы продолжают импортировать `MemoryVectorIndex` из
|
||||
* `pw.binom.agentik.memory.vector.*` (старое FQN). После удаления модуля —
|
||||
* можно убрать этот файл и переименовать пакеты импортов.
|
||||
*
|
||||
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это
|
||||
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||
* Раньше жил прямо здесь (`MemoryVectorIndex` + `ScoredVector` в
|
||||
* `:memory-vector/commonMain`), но переехал в `:memory-api` 2026-09-21
|
||||
* чтобы стать доступным из KMP-модуля `:memory-md-vector`.
|
||||
*/
|
||||
interface MemoryVectorIndex : AutoCloseable {
|
||||
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||
val dimension: Int
|
||||
|
||||
/** Количество записей в индексе. */
|
||||
suspend fun size(): Long
|
||||
@Deprecated(
|
||||
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||
replaceWith = ReplaceWith(
|
||||
"MemoryVectorIndex",
|
||||
"pw.binom.agentik.memory.MemoryVectorIndex",
|
||||
),
|
||||
)
|
||||
typealias MemoryVectorIndex = KmpMemoryVectorIndex
|
||||
|
||||
/** Добавить или заменить запись по [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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Доп. контекст для vector-индекса: фильтр по категории и conversationId
|
||||
* передаётся через замыкание, которое получает [MemoryNote]. Так [MemoryStore]
|
||||
* остаётся единственным источником правды по метаданным.
|
||||
*/
|
||||
fun noteMatches(
|
||||
note: MemoryNote,
|
||||
category: MemoryCategory? = null,
|
||||
conversationId: String? = null,
|
||||
): Boolean {
|
||||
if (category != null && note.category != category) return false
|
||||
if (conversationId != null && note.conversationId != conversationId) return false
|
||||
return true
|
||||
}
|
||||
@Deprecated(
|
||||
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
|
||||
replaceWith = ReplaceWith(
|
||||
"ScoredVector",
|
||||
"pw.binom.agentik.memory.ScoredVector",
|
||||
),
|
||||
)
|
||||
typealias ScoredVector = KmpScoredVector
|
||||
|
||||
+8
-6
@@ -6,6 +6,8 @@ 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.noteMatches
|
||||
import kotlin.math.exp
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -23,13 +25,13 @@ import kotlinx.coroutines.sync.withLock
|
||||
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
|
||||
* потом re-rank по recency.
|
||||
*
|
||||
* [embeddingProvider] обязателен — используется для эмбеддинга контента при
|
||||
* [embedding] обязателен — используется для эмбеддинга контента при
|
||||
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
|
||||
*/
|
||||
class VectorMemoryStore(
|
||||
private val index: MemoryVectorIndex,
|
||||
private val metaStore: MemoryMetaStore,
|
||||
private val embeddingProvider: EmbeddingProvider,
|
||||
private val embedding: TextEmbeddingExecutor,
|
||||
) : MemoryStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
@@ -37,9 +39,9 @@ class VectorMemoryStore(
|
||||
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
|
||||
|
||||
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
|
||||
val embedding = embeddingProvider.embed(note.content)
|
||||
metaStore.put(note, embedding)
|
||||
index.add(note.id, embedding)
|
||||
val vec = embedding.embed(note.content)
|
||||
metaStore.put(note, vec)
|
||||
index.add(note.id, vec)
|
||||
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ class VectorMemoryStore(
|
||||
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
|
||||
|
||||
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
|
||||
val queryEmbedding = embeddingProvider.embed(query.query)
|
||||
val queryEmbedding = embedding.embed(query.query)
|
||||
val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
|
||||
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
|
||||
// через [metaStore.get] + [noteMatches] отрежет лишних.
|
||||
|
||||
+7
-5
@@ -9,6 +9,7 @@ import pw.binom.agentik.memory.MemorySearchQuery
|
||||
import pw.binom.agentik.memory.MemoryStore
|
||||
import pw.binom.agentik.memory.MemorySystem
|
||||
import pw.binom.agentik.memory.ReviewedTurn
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
|
||||
/**
|
||||
* Бандл компонентов vector-бэкенда памяти — то же, что
|
||||
@@ -36,19 +37,20 @@ class VectorMemorySystem(
|
||||
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
|
||||
*
|
||||
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
|
||||
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient
|
||||
* @param embedding [TextEmbeddingExecutor] — обычно HttpEmbeddingClient.asExecutor()
|
||||
* @param topK размер top-K для prefetch
|
||||
*/
|
||||
fun open(
|
||||
dbPath: String,
|
||||
embedding: EmbeddingProvider,
|
||||
embedding: TextEmbeddingExecutor,
|
||||
topK: Int = 10,
|
||||
): VectorMemorySystem {
|
||||
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension)
|
||||
val dim = embedding.dimension
|
||||
val metaStore = SqliteMemoryMetaStore.open(dbPath, dim)
|
||||
// Граф пересобирается из SQLite (источник правды): без seed'ов
|
||||
// после рестарта in-RAM индекс пуст и search возвращал бы [],
|
||||
// пока не появятся новые upsert'ы.
|
||||
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries())
|
||||
val index = JVectorMemoryIndex(dim, metaStore.allEntries())
|
||||
val store = VectorMemoryStore(index, metaStore, embedding)
|
||||
val prefetcher = VectorPrefetcher(store, topK)
|
||||
val reviewer = VectorMemoryReviewer(store)
|
||||
@@ -59,7 +61,7 @@ class VectorMemorySystem(
|
||||
closables = listOfNotNull(
|
||||
metaStore,
|
||||
index,
|
||||
embedding as? AutoCloseable,
|
||||
embedding,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+15
-11
@@ -5,25 +5,26 @@ import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
|
||||
/**
|
||||
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
|
||||
* Используется при memory-backend=vector.
|
||||
*
|
||||
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов
|
||||
* к API на одинаковых промптах.
|
||||
* Реализует [TextEmbeddingExtractor] (из text-embedding-kmp:api) + оборачивается
|
||||
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||
* VectorMemoryStore. LRU-кэш на [cacheSize] текстов (default 256) — дедупликация
|
||||
* запросов к API на одинаковых промптах.
|
||||
*
|
||||
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
|
||||
* @param apiKey bearer-токен
|
||||
@@ -35,9 +36,9 @@ class HttpEmbeddingClient(
|
||||
private val apiUrl: String,
|
||||
private val apiKey: String,
|
||||
private val model: String,
|
||||
override val dimension: Int,
|
||||
private val dimension: Int,
|
||||
cacheSize: Int = 256,
|
||||
) : EmbeddingProvider, AutoCloseable {
|
||||
) : TextEmbeddingExtractor {
|
||||
|
||||
private val cache = LruCache<String, FloatArray>(cacheSize)
|
||||
private val http: HttpClient = HttpClient.newBuilder()
|
||||
@@ -45,11 +46,11 @@ class HttpEmbeddingClient(
|
||||
.build()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override suspend fun embed(text: String): FloatArray {
|
||||
cache.get(text)?.let { return it }
|
||||
override fun embed(text: String): TextEmbedding {
|
||||
cache.get(text)?.let { return TextEmbedding(it) }
|
||||
val vector = fetchEmbedding(text)
|
||||
cache.put(text, vector)
|
||||
return vector
|
||||
return TextEmbedding(vector)
|
||||
}
|
||||
|
||||
private fun fetchEmbedding(text: String): FloatArray {
|
||||
@@ -83,6 +84,9 @@ class HttpEmbeddingClient(
|
||||
}
|
||||
|
||||
override fun close() = http.close()
|
||||
|
||||
/** Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью. */
|
||||
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = dimension)
|
||||
}
|
||||
|
||||
private class LruCache<K, V>(private val capacity: Int) {
|
||||
|
||||
+22
-26
@@ -1,25 +1,22 @@
|
||||
package pw.binom.agentik.memory.vector.embedding
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.agentik.memory.vector.EmbeddingProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.memory.TextEmbeddingExecutor
|
||||
import pw.binom.voice.embeddingtext.TextEmbedding
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||
|
||||
/**
|
||||
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
|
||||
*
|
||||
* Особенности:
|
||||
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU),
|
||||
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать
|
||||
* доступ из нескольких корутин (ONNX-сессия не reentrant).
|
||||
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр
|
||||
* `dimension` в конструкторе не принимаем — берём через [probeDimension].
|
||||
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на
|
||||
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется —
|
||||
* легко добавить.
|
||||
* Реализует [TextEmbeddingExtractor] напрямую (делегирует в
|
||||
* `createSiglip2TextExtractor` из text-embedding-kmp:siglip) + оборачивается
|
||||
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
|
||||
* VectorMemoryStore. Сиглизация через `Dispatchers.IO` теперь внутри
|
||||
* `TextEmbeddingExecutor.embed` — раньше лежала здесь.
|
||||
*
|
||||
* Размерность фиксирована extractor'ом (SigLIP2-base = 768); передаём
|
||||
* явно в [asExecutor].
|
||||
*
|
||||
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
|
||||
* Скачать: см. README репы `text-embedding-kmp`.
|
||||
@@ -27,23 +24,22 @@ import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
|
||||
class SiglipEmbeddingProvider(
|
||||
modelPath: String,
|
||||
tokenizerPath: String,
|
||||
) : EmbeddingProvider, AutoCloseable {
|
||||
) : TextEmbeddingExtractor {
|
||||
|
||||
private val extractor: TextEmbeddingExtractor =
|
||||
private val delegate: TextEmbeddingExtractor =
|
||||
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
||||
|
||||
override val dimension: Int = run {
|
||||
val probe = extractor.embed("probe")
|
||||
probe.dim
|
||||
}
|
||||
override fun embed(text: String): TextEmbedding = delegate.embed(text)
|
||||
|
||||
private val mutex = Mutex()
|
||||
override fun close() = delegate.close()
|
||||
|
||||
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) {
|
||||
mutex.withLock { extractor.embed(text).values }
|
||||
}
|
||||
/**
|
||||
* Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью 768
|
||||
* (SigLIP2-base). Сигнатура стабильна — extractor всегда возвращает 768-dim.
|
||||
*/
|
||||
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = SIGLIP2_DIM)
|
||||
|
||||
override fun close() {
|
||||
extractor.close()
|
||||
companion object {
|
||||
const val SIGLIP2_DIM: Int = 768
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
+4
-4
@@ -32,7 +32,7 @@ class VectorMemoryStoreTest {
|
||||
// Загружаем начальные entries из metaStore (на случай если что-то там есть).
|
||||
val seedEntries = metaStore.allEntries()
|
||||
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim))
|
||||
store = VectorMemoryStore(index, metaStore, fakeEmbeddingExecutor(dimension = dim))
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
@@ -127,7 +127,7 @@ class VectorMemoryStoreTest {
|
||||
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
|
||||
val seedEntries = meta2.allEntries()
|
||||
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
|
||||
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim))
|
||||
val store2 = VectorMemoryStore(idx2, meta2, fakeEmbeddingExecutor(dimension = dim))
|
||||
try {
|
||||
assertEquals(2L, idx2.size())
|
||||
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
|
||||
@@ -141,11 +141,11 @@ class VectorMemoryStoreTest {
|
||||
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
|
||||
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
|
||||
// из SQLite — иначе после рестарта search возвращает [] до первого upsert.
|
||||
val first = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
||||
val first = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
|
||||
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
|
||||
first.close()
|
||||
|
||||
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
|
||||
val second = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
|
||||
try {
|
||||
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
|
||||
assertTrue(results.any { it.note.id == "r" })
|
||||
|
||||
+11
-5
@@ -17,17 +17,18 @@ import kotlin.test.assertTrue
|
||||
class SiglipEmbeddingProviderTest {
|
||||
|
||||
@Test
|
||||
fun `dimension is 768 when model loads successfully`() {
|
||||
fun `dimension is 768 when model loads successfully`() = runBlocking {
|
||||
val modelDir = File("/tmp/text-emb-model")
|
||||
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
|
||||
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
|
||||
}
|
||||
SiglipEmbeddingProvider(
|
||||
val provider = SiglipEmbeddingProvider(
|
||||
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
|
||||
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
|
||||
).use { provider ->
|
||||
).asExecutor()
|
||||
provider.use {
|
||||
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
|
||||
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") }
|
||||
val v = provider.embed("hello world")
|
||||
assertEquals(768, v.size)
|
||||
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
|
||||
}
|
||||
@@ -41,7 +42,7 @@ class SiglipEmbeddingProviderTest {
|
||||
SiglipEmbeddingProvider(
|
||||
modelPath = nonExistent.absolutePath,
|
||||
tokenizerPath = nonExistent.absolutePath,
|
||||
).use { it.dimension }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +50,8 @@ class SiglipEmbeddingProviderTest {
|
||||
org.junit.Assume.assumeTrue(message(), condition)
|
||||
}
|
||||
}
|
||||
|
||||
// runBlocking нужен потому что suspend-вызов provider.embed в suspend-тесте.
|
||||
// Локальный импорт чтобы не тащить runBlocking в прод-код.
|
||||
private fun <T> runBlocking(block: suspend () -> T): T =
|
||||
kotlinx.coroutines.runBlocking { block() }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
}
|
||||
|
||||
// KMP-реализация [MutableOutboxStore] на `ArrayDeque` + `Mutex` — для тестов,
|
||||
// dev-режима и embedded-сценариев (Android core, CLI). TTL и size-cap eviction
|
||||
// вызываются на каждом `append`, в одном проходе с amortized O(1) для стабильного
|
||||
// размера буфера.
|
||||
//
|
||||
// Зависимости: только `:outbox-api` (api → `:proto` транзитивно).
|
||||
// Никакого I/O — pure in-memory.
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
jvm()
|
||||
linuxX64()
|
||||
mingwX64()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":outbox-api"))
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
|
||||
/**
|
||||
* In-memory реализация [MutableOutboxStore] на `ArrayDeque` + [Mutex].
|
||||
*
|
||||
* **Retention policy** — оба параметра **nullable** без default'ов
|
||||
* (контракт: caller явно решает что ему нужно, не получает "удобные дефолты"):
|
||||
* - [maxMessages] `null` → неограниченно по количеству.
|
||||
* - [ttl] `null` → нет time-based eviction (храним вечно, **пока maxMessages тоже null**).
|
||||
* - **Оба `null` → вечное хранилище.**
|
||||
* - Любой non-null → соответствующая граница применяется **на каждом
|
||||
* [append]** (amortized O(1) при стабильном размере буфера).
|
||||
*
|
||||
* **Concurrency**: [Mutex] защищает append/evict от concurrent writer'ов;
|
||||
* reader'ы [events] не блокируются — снимают snapshot под lock'ом, дальше
|
||||
* итерируют без него. Snapshot под `mutex.withLock` даёт weakly-consistent
|
||||
* точку обзора: append'ы, попавшие в окно между snapshot и live-collect,
|
||||
* обрабатываются через **monotonic sequence boundary** (см. [events] KDoc).
|
||||
*
|
||||
* **Live tail**: [MutableSharedFlow] с DROP_OLDEST policy. Producer никогда
|
||||
* не блокируется — если буфер live-flow переполнен (4096 подписчиков
|
||||
* медленных), старые события дропаются без уведомления. Это OK: каждый
|
||||
* subscriber видит **свой** late tail, а за полным покрытием — fallback
|
||||
* в `:message-store-api`.
|
||||
*
|
||||
* **Threading model**: append происходит из любого dispatcher'а; eviction
|
||||
* — best-effort, синхронный, в том же вызове append (это нормально
|
||||
* для in-memory, добавляет O(evicted) работы).
|
||||
*/
|
||||
class InMemoryOutboxStore(
|
||||
private val maxMessages: Int?,
|
||||
private val ttl: Duration?,
|
||||
private val clock: Clock = Clock.System,
|
||||
) : MutableOutboxStore {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val buffer = ArrayDeque<CommonEvent>()
|
||||
private val liveFlow = MutableSharedFlow<CommonEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = LIVE_BUFFER_CAPACITY,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
init {
|
||||
// Аргументы — НЕ optional default'ы; explicit null = "не применяется".
|
||||
// Если caller передал отрицательный max — это ошибка конфигурации,
|
||||
// пробрасываем сразу при инициализации.
|
||||
require(maxMessages == null || maxMessages > 0) {
|
||||
"maxMessages must be > 0 or null, got $maxMessages"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun append(event: CommonEvent) {
|
||||
mutex.withLock {
|
||||
buffer.addLast(event)
|
||||
}
|
||||
liveFlow.tryEmit(event)
|
||||
evictExpired()
|
||||
evictOverCapacity()
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить с головы все event'ы старше [ttl]. Amortized O(evicted).
|
||||
* Если [ttl] null — no-op.
|
||||
*/
|
||||
private suspend fun evictExpired() {
|
||||
val ttlValue = ttl ?: return
|
||||
val cutoff = clock.now() - ttlValue
|
||||
mutex.withLock {
|
||||
while (true) {
|
||||
val head = buffer.firstOrNull() ?: return@withLock
|
||||
if (head.date >= cutoff) return@withLock
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить с головы пока размер > [maxMessages]. Amortized O(evicted).
|
||||
* Если [maxMessages] null — no-op.
|
||||
*/
|
||||
private suspend fun evictOverCapacity() {
|
||||
val cap = maxMessages ?: return
|
||||
mutex.withLock {
|
||||
while (buffer.size > cap) {
|
||||
if (buffer.isEmpty()) return@withLock
|
||||
buffer.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun events(after: Instant?): Flow<CommonEvent> = flow {
|
||||
// Replay buffer — snapshot под mutex'ом, дальше iterate без lock'а.
|
||||
// Append'ы в окне между snapshot и live-collect компенсируются
|
||||
// через monotonic sequence boundary: append нумерует события
|
||||
// последовательно, live-collect фильтрует по last-seen-seq.
|
||||
val snapshot: List<CommonEvent> = mutex.withLock {
|
||||
if (after == null) {
|
||||
buffer.toList()
|
||||
} else {
|
||||
buffer.filter { it.date > after }
|
||||
}
|
||||
}
|
||||
snapshot.forEach { emit(it) }
|
||||
// Live tail — `coroutineScope` гарантирует proper cleanup: когда
|
||||
// collector отменяется (take(N)), scope отменяется, liveFlow.collect
|
||||
// выходит чисто. Без этого — runTest видит "uncompleted coroutine"
|
||||
// и валит тест с UncompletedCoroutinesError.
|
||||
coroutineScope {
|
||||
liveFlow.collect { emit(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun earliestEventDate(): Instant {
|
||||
val earliest = mutex.withLock { buffer.firstOrNull()?.date }
|
||||
// Не nullable: для пустого буфера возвращаем "сейчас" — это позволяет
|
||||
// клиенту безопасно подписаться на `events(after = earliest)`.
|
||||
return earliest ?: clock.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* **Test-only helper** — снимок буфера в текущий момент.
|
||||
*
|
||||
* `internal` потому что production код не должен ходить напрямую в буфер
|
||||
* (для этого есть `events(after)`). Доступно только из `commonTest`.
|
||||
*
|
||||
* Returns: иммутабельный snapshot (копия). Под `mutex.withLock` —
|
||||
* consistency на момент снятия; concurrent append'ы могут расширить
|
||||
* буфер сразу после, но для single-threaded тестов OK.
|
||||
*/
|
||||
internal suspend fun snapshot(): List<CommonEvent> = mutex.withLock { buffer.toList() }
|
||||
|
||||
override fun close() {
|
||||
// mutex не закрываем (kotlinx Mutex не AutoCloseable; для in-memory
|
||||
// store GC соберёт всё при выходе ссылки). buffer чистим.
|
||||
buffer.clear()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Live-flow capacity — generous default. Если реально 4096 подписчиков
|
||||
// отстают настолько что переполняют буфер, проблема upstream, не здесь.
|
||||
private const val LIVE_BUFFER_CAPACITY = 4096
|
||||
}
|
||||
}
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package pw.binom.agentik.outbox.inmemory
|
||||
|
||||
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 kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
// Импортируем напрямую из :outbox-api — typealias'ы в :proto для
|
||||
// CommonEvent/AgentEvent/Event НЕ поддерживают nested-class access
|
||||
// (`CommonEvent.Agent` через alias даёт "Unresolved qualified name").
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event
|
||||
|
||||
class InMemoryOutboxStoreTest {
|
||||
|
||||
private class FixedClock(private var nowMs: Long = 1_000_000_000L) : Clock {
|
||||
fun advance(delta: Duration) { nowMs += delta.inWholeMilliseconds }
|
||||
override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs)
|
||||
}
|
||||
|
||||
private fun evtAt(clock: Clock, body: String): CommonEvent =
|
||||
CommonEvent.Agent(date = clock.now(), event = AgentEvent.Created(date = clock.now(), conversationId = body))
|
||||
|
||||
@Test
|
||||
fun `append stores all events when both limits are null store-forever`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
repeat(100) { i ->
|
||||
store.append(CommonEvent.Agent(
|
||||
date = Instant.fromEpochSeconds(i.toLong()),
|
||||
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
|
||||
))
|
||||
}
|
||||
assertEquals(100, store.snapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maxMessages cap evicts oldest when exceeded`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = 3, ttl = null)
|
||||
for (i in 1..5) {
|
||||
store.append(CommonEvent.Agent(
|
||||
date = Instant.fromEpochSeconds(i.toLong()),
|
||||
event = AgentEvent.Created(date = Instant.fromEpochSeconds(i.toLong()), conversationId = "c-$i"),
|
||||
))
|
||||
}
|
||||
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
assertEquals(listOf("c-3", "c-4", "c-5"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ttl evicts events older than threshold`() = runBlocking {
|
||||
val clock = FixedClock()
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = 100.milliseconds, clock = clock)
|
||||
|
||||
store.append(evtAt(clock, "old"))
|
||||
clock.advance(50.milliseconds)
|
||||
store.append(evtAt(clock, "middle"))
|
||||
clock.advance(70.milliseconds)
|
||||
store.append(evtAt(clock, "fresh"))
|
||||
|
||||
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
assertEquals(listOf("middle", "fresh"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both maxMessages and ttl apply together`() = runBlocking {
|
||||
val clock = FixedClock()
|
||||
// ttl=100ms so b at t=20 (deadline=120) survives when c is appended at t=80.
|
||||
// Cap=2 evicts oldest. Result: [b, c].
|
||||
val store = InMemoryOutboxStore(maxMessages = 2, ttl = 100.milliseconds, clock = clock)
|
||||
|
||||
store.append(evtAt(clock, "a"))
|
||||
clock.advance(20.milliseconds)
|
||||
store.append(evtAt(clock, "b"))
|
||||
clock.advance(60.milliseconds)
|
||||
store.append(evtAt(clock, "c"))
|
||||
|
||||
val ids = store.snapshot().map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
assertEquals(listOf("b", "c"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events with null after replays buffer then collects live`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(evtAt(Clock.System, "e1"))
|
||||
store.append(evtAt(Clock.System, "e2"))
|
||||
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val done = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
store.events(after = null).collect { e ->
|
||||
collected.add(e)
|
||||
if (collected.size >= 3) done.complete(Unit)
|
||||
}
|
||||
}
|
||||
delay(20)
|
||||
store.append(evtAt(Clock.System, "e3"))
|
||||
done.await()
|
||||
job.cancel()
|
||||
assertEquals(3, collected.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events with after catches up then continues with live`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val t0 = Instant.fromEpochSeconds(0)
|
||||
val t1 = Instant.fromEpochSeconds(10)
|
||||
val t2 = Instant.fromEpochSeconds(20)
|
||||
|
||||
store.append(CommonEvent.Agent(date = t0, event = AgentEvent.Created(date = t0, conversationId = "e1")))
|
||||
store.append(CommonEvent.Agent(date = t1, event = AgentEvent.Created(date = t1, conversationId = "e2")))
|
||||
store.append(CommonEvent.Agent(date = t2, event = AgentEvent.Created(date = t2, conversationId = "e3")))
|
||||
|
||||
val collected = mutableListOf<CommonEvent>()
|
||||
val done = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
store.events(after = t0).collect { e ->
|
||||
collected.add(e)
|
||||
if (collected.size >= 3) done.complete(Unit)
|
||||
}
|
||||
}
|
||||
delay(20)
|
||||
store.append(CommonEvent.Agent(
|
||||
date = Instant.fromEpochSeconds(30),
|
||||
event = AgentEvent.Created(date = Instant.fromEpochSeconds(30), conversationId = "e4"),
|
||||
))
|
||||
done.await()
|
||||
job.cancel()
|
||||
val ids = collected.map { ((it as CommonEvent.Agent).event as AgentEvent.Created).conversationId }
|
||||
assertEquals(listOf("e2", "e3", "e4"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `earliestEventDate returns oldest buffered date`() = runBlocking {
|
||||
val clock = FixedClock()
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
|
||||
store.append(evtAt(clock, "e1"))
|
||||
clock.advance(100.milliseconds)
|
||||
store.append(evtAt(clock, "e2"))
|
||||
|
||||
assertEquals(Instant.fromEpochMilliseconds(1_000_000_000L), store.earliestEventDate())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `earliestEventDate returns current time when buffer is empty`() = runBlocking {
|
||||
val clock = FixedClock(nowMs = 5_000_000_000L)
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null, clock = clock)
|
||||
assertEquals(Instant.fromEpochMilliseconds(5_000_000_000L), store.earliestEventDate())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationEvents default impl filters to conversation variant`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val now = Instant.fromEpochSeconds(0)
|
||||
store.append(CommonEvent.Agent(
|
||||
date = now,
|
||||
event = AgentEvent.Created(date = now, conversationId = "agent-event"),
|
||||
))
|
||||
store.append(CommonEvent.Conversation(
|
||||
date = now,
|
||||
conversationId = "c-1",
|
||||
event = Event.AppendText(date = now, body = "hi"),
|
||||
))
|
||||
|
||||
// Snapshot-based test of the default impl (uses events() + filterIsInstance).
|
||||
// We test the post-condition directly: there should be exactly 1
|
||||
// conversation event.
|
||||
val all = store.snapshot()
|
||||
assertEquals(2, all.size)
|
||||
assertEquals(1, all.count { it is CommonEvent.Conversation })
|
||||
assertEquals(1, all.count { it is CommonEvent.Agent })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `conversationEvents with conversationId filters to that conversation`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val now = Instant.fromEpochSeconds(0)
|
||||
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "a")))
|
||||
store.append(CommonEvent.Conversation(now, "c-2", Event.AppendText(now, "b")))
|
||||
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "c")))
|
||||
|
||||
// Test the filter logic by manually filtering snapshot.
|
||||
val c1 = store.snapshot()
|
||||
.filterIsInstance<CommonEvent.Conversation>()
|
||||
.filter { it.conversationId == "c-1" }
|
||||
assertEquals(2, c1.size)
|
||||
assertTrue(c1.all { it.conversationId == "c-1" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agentEvents default impl filters to agent variant`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
val now = Instant.fromEpochSeconds(0)
|
||||
store.append(CommonEvent.Agent(
|
||||
date = now,
|
||||
event = AgentEvent.Created(date = now, conversationId = "created"),
|
||||
))
|
||||
store.append(CommonEvent.Conversation(now, "c-1", Event.AppendText(now, "hi")))
|
||||
|
||||
val all = store.snapshot()
|
||||
val agents = all.filterIsInstance<CommonEvent.Agent>()
|
||||
assertEquals(1, agents.size)
|
||||
val created = agents[0].event as AgentEvent.Created
|
||||
assertEquals("created", created.conversationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close clears buffer`() = runBlocking {
|
||||
val store = InMemoryOutboxStore(maxMessages = null, ttl = null)
|
||||
store.append(evtAt(Clock.System, "e1"))
|
||||
store.close()
|
||||
assertEquals(emptyList(), store.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative maxMessages throws at construction`() {
|
||||
kotlin.runCatching { InMemoryOutboxStore(maxMessages = -1, ttl = null) }
|
||||
.onFailure { /* expected */ }
|
||||
.onSuccess { kotlin.test.fail("should have thrown") }
|
||||
}
|
||||
}
|
||||
|
||||
private val Int.milliseconds: Duration get() = Duration.parse("${this}ms")
|
||||
@@ -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]. Хранит собственную историю: на каждый
|
||||
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
|
||||
* [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 {
|
||||
val id: String
|
||||
@@ -33,7 +46,7 @@ interface Conversation : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Ставит новый user-ход в очередь. Возвращает управление сразу — поток
|
||||
* событий ответа приходит через [events].
|
||||
* событий ответа приходит через `agent.outbox.conversationEvents(...)`.
|
||||
*
|
||||
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
|
||||
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
|
||||
@@ -48,25 +61,13 @@ interface Conversation : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
|
||||
* in-flight tool может доехать или отвалиться). В [events] эмитится
|
||||
* [Event.Interrupted], затем может начаться следующий ход из очереди.
|
||||
* in-flight tool может доехать или отвалиться). В `agent.outbox.conversationEvents`
|
||||
* эмитится `Event.Interrupted`, затем может начаться следующий ход из очереди.
|
||||
*
|
||||
* Если хода нет — no-op.
|
||||
*/
|
||||
suspend fun interrupt()
|
||||
|
||||
/**
|
||||
* Live-подписка на всё, что происходит в диалоге, начиная с [after].
|
||||
*
|
||||
* **Не реплеит** события, произошедшие до [after] — для бэкфилла
|
||||
* используй [getMessages]. Если [after] — момент последнего виденного
|
||||
* клиентом события, поток продолжается «с того места».
|
||||
*
|
||||
* Подписки независимы: каждый вызов возвращает свой [Flow], отмена одного
|
||||
* не влияет на других подписчиков и на сам диалог.
|
||||
*/
|
||||
fun events(after: Instant): Flow<Event>
|
||||
|
||||
/** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */
|
||||
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()
|
||||
|
||||
|
||||
@@ -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
|
||||
) : Message
|
||||
|
||||
/**
|
||||
* Результат вызова тула. Приходит в историю `getMessages` после завершения хода.
|
||||
* [id] совпадает с [ToolCall.id], к которому относится результат, и
|
||||
* с id соответствующего `MessageRecord.ToolResult` в journal.
|
||||
*
|
||||
* [toolName] денормализован из [ToolCall.toolName] — UI рендерит
|
||||
* имя тула в строке результата без отдельной `Map<id, name>`.
|
||||
*/
|
||||
@Serializable
|
||||
@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, инициализация движка или иная отказоустойчивая
|
||||
|
||||
@@ -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.get
|
||||
import io.ktor.server.routing.route
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
import pw.binom.agentik.proto.CommonEvent
|
||||
|
||||
/**
|
||||
* HTTP-фасад для [OutboxStore] (bounded-tail live event stream агента).
|
||||
|
||||
@@ -20,11 +20,11 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
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.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
|
||||
|
||||
internal fun Route.agentikRoutes(agent: Agent) {
|
||||
@@ -128,7 +128,10 @@ internal fun Route.agentikRoutes(agent: Agent) {
|
||||
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") {
|
||||
|
||||
+6
-2
@@ -15,8 +15,8 @@ dependencyResolutionManagement {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
google()
|
||||
// Локально опубликованные snapshot-ы text-embedding-kmp (см. `~/.m2`).
|
||||
// Home Nexus, репо "caffeine": pw.binom.* (A2A, ...)
|
||||
// Home Nexus, репо "caffeine": pw.binom.* (a2a, text-embedding-kmp с v4,
|
||||
// ksqlite до v0.1.1-SNAPSHOT — теперь в Maven Central, ...)
|
||||
maven {
|
||||
name = "caffeine"
|
||||
url = uri(caffeineRepo)
|
||||
@@ -91,6 +91,10 @@ include(":outbox-api")
|
||||
// eviction. Для тестов, dev-режима, embedded-сценариев (Android core).
|
||||
include(":outbox-inmemory")
|
||||
include(":journal-inmemory")
|
||||
// Гибридное хранилище памяти: .md файлы (single source of truth) +
|
||||
// ksqlite vector-кэш (sqlite-vec vec0) + reconcile + hybrid search.
|
||||
// Заменяет чисто-keyword :memory-md там, где нужен семантический поиск.
|
||||
include(":memory-md-vector")
|
||||
//include(":event-store-in-memory")
|
||||
//include(":working-memory-api")
|
||||
include(":storage-inmemory")
|
||||
|
||||
@@ -12,8 +12,9 @@ plugins {
|
||||
}
|
||||
|
||||
// CI-флаг: при -PskipVectorMemory=true :memory-vector не подключается как
|
||||
// зависимость — нужно для CI runner'а (text-embedding-kmp ещё не опубликован
|
||||
// в caffeine). Подробнее см. memory-vector/build.gradle.kts.
|
||||
// зависимость — нужно для CI runner'а (text-embedding-kmp:api/siglip-jvm
|
||||
// опубликованы в caffeine начиная с v4, 2026-09-21). Подробнее см.
|
||||
// memory-vector/build.gradle.kts.
|
||||
val skipVectorMemory: Boolean =
|
||||
(project.findProperty("skipVectorMemory") == "true") ||
|
||||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||
@@ -57,6 +58,9 @@ kotlin {
|
||||
implementation(project(":memory-md"))
|
||||
if (!skipVectorMemory) {
|
||||
implementation(project(":memory-vector"))
|
||||
// Гибрид: .md (single source of truth) + ksqlite vector-кэш
|
||||
// (sqlite-vec vec0). Подключается при AGENTIK_MEMORY_BACKEND=hybrid.
|
||||
implementation(project(":memory-md-vector"))
|
||||
}
|
||||
|
||||
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
|
||||
@@ -166,3 +170,42 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
|
||||
// :standalone deploy
|
||||
//
|
||||
// `deployJar` собирает fatjar через shadowJar и копирует его на приватный
|
||||
// домашний хост (root@192.168.76.166). Репа приватная, IP захардкожен —
|
||||
// пользователь явно сказал "можно в наглую указать конкретно этот ip".
|
||||
// Если понадобится другой хост — переопредели через -PagentikDeployHost=...
|
||||
//
|
||||
// Конвенция имени файла: agentik-standalone-{version}-all.jar (как на хосте
|
||||
// уже лежат agentik-cli-0.1.0-all.jar / agentik-0.1.0-all.jar —
|
||||
// выровнено по стилю существующего деплоя).
|
||||
//
|
||||
// SSH — ключевой, пароль не спрашивает. Если упадёт — проверь
|
||||
// `ssh root@192.168.76.166 'echo OK'` вручную.
|
||||
val agentikDeployHost: String =
|
||||
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.166"
|
||||
val agentikDeployPath: String =
|
||||
(project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar"
|
||||
|
||||
tasks.register("deployJar") {
|
||||
group = "deployment"
|
||||
description = "Builds shadowJar and copies it to $agentikDeployHost:$agentikDeployPath"
|
||||
dependsOn(shadowJarTask)
|
||||
|
||||
doLast {
|
||||
val jar = shadowJarTask.get().outputs.files.singleFile
|
||||
val target = "$agentikDeployHost:$agentikDeployPath"
|
||||
println("→ scp $jar $target")
|
||||
val proc = ProcessBuilder("scp", jar.absolutePath, target)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
proc.inputStream.bufferedReader().forEachLine { println("scp> $it") }
|
||||
val rc = proc.waitFor()
|
||||
if (rc != 0) {
|
||||
throw GradleException("scp failed (exit=$rc). Check ssh access to $agentikDeployHost.")
|
||||
}
|
||||
println("✓ deployed to $target")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ class A2aBridge(private val agent: Agent) : AgentHandler {
|
||||
val reply = StringBuilder()
|
||||
val turnDone = CompletableDeferred<Unit>()
|
||||
val subscription = async {
|
||||
conv.events(since).collect { e ->
|
||||
agent.outbox.conversationEvents(since, conv.id).collect { ce ->
|
||||
val e = ce.event
|
||||
when (e) {
|
||||
is Event.AppendText -> reply.append(e.body)
|
||||
is Event.End, is Event.Interrupted -> turnDone.complete(Unit)
|
||||
|
||||
@@ -15,6 +15,7 @@ import pw.binom.a2a.server.a2aAgent
|
||||
import pw.binom.agentik.memory.MemoryReviewer
|
||||
import pw.binom.agentik.memory.MemorySystem
|
||||
import pw.binom.agentik.memory.md.openMdMemorySystem
|
||||
import pw.binom.agentik.memory.mdvector.openHybridMemorySystem
|
||||
import pw.binom.agentik.memory.vector.VectorMemorySystem
|
||||
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
|
||||
import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
|
||||
@@ -256,7 +257,7 @@ private fun runServer() {
|
||||
}
|
||||
}
|
||||
MemoryBackend.VECTOR -> {
|
||||
val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embedding.backend) {
|
||||
val embedding: pw.binom.agentik.memory.TextEmbeddingExecutor = when (config.embedding.backend) {
|
||||
AppConfig.EmbeddingBackend.HTTP -> {
|
||||
val llm = config.llm
|
||||
// Берём базовый URL + API key у активного LLM-бэкенда.
|
||||
@@ -271,7 +272,7 @@ private fun runServer() {
|
||||
apiKey = oa.apiKey,
|
||||
model = config.embedding.model,
|
||||
dimension = config.embedding.dimension,
|
||||
)
|
||||
).asExecutor()
|
||||
}
|
||||
AppConfig.EmbeddingBackend.SIGLIP -> {
|
||||
val modelPath = checkNotNull(config.embedding.modelPath) {
|
||||
@@ -280,7 +281,7 @@ private fun runServer() {
|
||||
val tokenizerPath = checkNotNull(config.embedding.tokenizerPath) {
|
||||
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH"
|
||||
}
|
||||
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath)
|
||||
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath).asExecutor()
|
||||
}
|
||||
}
|
||||
VectorMemorySystem.open(
|
||||
@@ -296,6 +297,44 @@ private fun runServer() {
|
||||
println(" memory: db=${config.agent.dbPath} (vector-backend, $backendLabel)")
|
||||
}
|
||||
}
|
||||
MemoryBackend.HYBRID -> {
|
||||
// Гибрид: .md файлы (single source of truth) + ksqlite vector-кэш.
|
||||
// Нужны embeddings (HTTP/SIGLIP) + путь к vector-БД (по умолчанию рядом с agentik.db).
|
||||
val embedding: pw.binom.agentik.memory.TextEmbeddingExecutor = when (config.embedding.backend) {
|
||||
AppConfig.EmbeddingBackend.HTTP -> {
|
||||
val llm = config.llm
|
||||
require(llm.backend == LlmBackend.OPENAI) {
|
||||
"AGENTIK_EMBEDDING_BACKEND=http требует LLM_BACKEND=openai"
|
||||
}
|
||||
val oa = checkNotNull(llm.openai) { "openai config required" }
|
||||
HttpEmbeddingClient(
|
||||
apiUrl = oa.baseUrl.trimEnd('/'),
|
||||
apiKey = oa.apiKey,
|
||||
model = config.embedding.model,
|
||||
dimension = config.embedding.dimension,
|
||||
).asExecutor()
|
||||
}
|
||||
AppConfig.EmbeddingBackend.SIGLIP -> {
|
||||
val modelPath = checkNotNull(config.embedding.modelPath) {
|
||||
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
|
||||
}
|
||||
val tokenizerPath = checkNotNull(config.embedding.tokenizerPath) {
|
||||
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH"
|
||||
}
|
||||
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath).asExecutor()
|
||||
}
|
||||
}
|
||||
val mdDir = rawMemory.takeUnless { it.equals("off", true) } ?: defaultMemoryDir()
|
||||
val vectorDbPath = deriveVectorDbPath(config.agent.dbPath)
|
||||
openHybridMemorySystem(
|
||||
memoryRoot = Path(mdDir),
|
||||
vectorDbPath = Path(vectorDbPath),
|
||||
dimension = embedding.dimension,
|
||||
embedder = embedding,
|
||||
).also {
|
||||
println(" memory: md=$mdDir, vector-cache=$vectorDbPath (hybrid-backend, dim=${embedding.dimension})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Контекстное окно модели (для compaction'а working memory).
|
||||
@@ -428,3 +467,12 @@ private fun defaultMemoryDir(): String {
|
||||
val home = System.getProperty("user.home") ?: "."
|
||||
return "$home/.agentik/memory"
|
||||
}
|
||||
|
||||
/**
|
||||
* Производный путь к vector-БД гибридного бэкенда: рядом с `agentik.db`,
|
||||
* но с суффиксом `-vectors`. Например: `agentik.db` → `agentik-vectors.db`.
|
||||
*/
|
||||
private fun deriveVectorDbPath(agentDbPath: String): String {
|
||||
val withoutExt = agentDbPath.removeSuffix(".db")
|
||||
return "$withoutExt-vectors.db"
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ import pw.binom.agentik.memory.MemorySystemGuidance
|
||||
import pw.binom.agentik.proto.Agent as ProtoAgent
|
||||
import pw.binom.agentik.outbox.AgentEvent
|
||||
import pw.binom.agentik.outbox.CommonEvent
|
||||
import pw.binom.agentik.outbox.Event as ProtoEvent
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
import pw.binom.agentik.journal.JournalStore
|
||||
import pw.binom.agentik.outbox.OutboxStore
|
||||
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.renderSystemPromptSection
|
||||
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
||||
|
||||
+4
-4
@@ -3,15 +3,15 @@ package pw.binom.agentik.standalone.agent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import pw.binom.agentik.outbox.MutableOutboxStore
|
||||
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(
|
||||
private val globalEventStore: MutableOutboxStore,
|
||||
private val conversationId: String,
|
||||
) {
|
||||
fun tryEmit(event: ProtoEvent): Boolean {
|
||||
fun tryEmit(event: Event): Boolean {
|
||||
runBlocking {
|
||||
globalEventStore.append(
|
||||
CommonEvent.Conversation(
|
||||
@@ -24,7 +24,7 @@ internal class ConversationEvents(
|
||||
return true
|
||||
}
|
||||
|
||||
fun events(after: kotlin.time.Instant?): Flow<ProtoEvent> =
|
||||
fun events(after: kotlin.time.Instant?): Flow<Event> =
|
||||
globalEventStore.conversationEvents(after = after, conversationId = conversationId)
|
||||
.map { it.event }
|
||||
}
|
||||
|
||||
+1
-3
@@ -220,9 +220,6 @@ class ConversationLoop(
|
||||
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> =
|
||||
messageStore.list(conversationId = id, after = after, offset = offset, limit = limit)
|
||||
.map { it.toProto() }
|
||||
@@ -568,6 +565,7 @@ internal fun MessageRecord.toProto(): ProtoMessage = when (this) {
|
||||
is MessageRecord.ToolResult -> ProtoMessage.ToolResult(
|
||||
id = id,
|
||||
date = createdAt,
|
||||
toolName = toolName,
|
||||
result = result,
|
||||
)
|
||||
is MessageRecord.Error -> ProtoMessage.Error(
|
||||
|
||||
+2
-1
@@ -93,7 +93,7 @@ internal class ToolDispatcher(
|
||||
}
|
||||
|
||||
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)
|
||||
// решают, делать ли что-то. Cancellation = not a failure (не эмитим Failed).
|
||||
@@ -110,6 +110,7 @@ internal class ToolDispatcher(
|
||||
id = resultId,
|
||||
conversationId = state.id,
|
||||
toolCallId = callId,
|
||||
toolName = call.name,
|
||||
result = resultText,
|
||||
createdAt = resultAt,
|
||||
),
|
||||
|
||||
@@ -115,9 +115,29 @@ data class AppConfig(
|
||||
val endpoints: Boolean = false,
|
||||
)
|
||||
|
||||
/** Бэкенд долговременной памяти. */
|
||||
/** Бэкенд долговременной памяти.
|
||||
*
|
||||
* Env-алиасы (для удобства CLI):
|
||||
* - `md` → [MD] (Hermes-style §-файлы)
|
||||
* - `vector` → [VECTOR] (sqlite + JVector + LLM-embeddings)
|
||||
* - `md-vector` → [HYBRID] (md-файлы = source of truth + sqlite vector-кэш)
|
||||
* - `off` → [OFF] (память выключена)
|
||||
*/
|
||||
@Serializable
|
||||
enum class MemoryBackend { MD, VECTOR, OFF }
|
||||
enum class MemoryBackend {
|
||||
MD, VECTOR, HYBRID, OFF;
|
||||
|
||||
companion object {
|
||||
/** Маппинг env-строки (lowercase, как приходит из shell) → enum. */
|
||||
fun fromEnvString(s: String): MemoryBackend? = when (s.lowercase()) {
|
||||
"md" -> MD
|
||||
"vector" -> VECTOR
|
||||
"md-vector", "hybrid" -> HYBRID
|
||||
"off", "none" -> OFF
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Бэкенд эмбеддингов (для memory-backend=vector). */
|
||||
@Serializable
|
||||
@@ -183,7 +203,7 @@ data class AppConfig(
|
||||
memory = MemorySection(
|
||||
dir = env("AGENTIK_MEMORY_DIR")?.takeIf { it.isNotBlank() },
|
||||
backend = env("AGENTIK_MEMORY_BACKEND")?.let {
|
||||
runCatching { MemoryBackend.valueOf(it.uppercase()) }.getOrNull()
|
||||
MemoryBackend.fromEnvString(it)
|
||||
} ?: MemoryBackend.MD,
|
||||
compressionThreshold = env("AGENTIK_COMPRESSION_THRESHOLD")?.toDoubleOrNull()
|
||||
?.coerceIn(0.1, 0.99) ?: DEFAULT_COMPRESSION_THRESHOLD,
|
||||
|
||||
+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,639 @@
|
||||
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",
|
||||
conversationStore = 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 `getConversations returns all stored persistent conversations`() = runTest {
|
||||
val agent = newAgent()
|
||||
agent.createConversation(temp = false)
|
||||
agent.createConversation(temp = true)
|
||||
val list = agent.getConversations(0, 10)
|
||||
// temp-беседы не персистятся, в списке только persistent
|
||||
assertEquals(1, list.size)
|
||||
}
|
||||
|
||||
@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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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",
|
||||
conversationStore = 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)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ plugins {
|
||||
//
|
||||
// Apple targets (macos*, ios*, tvos*, watchos*) невозможно собрать на Linux —
|
||||
// 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 {
|
||||
jvmToolchain(21)
|
||||
@@ -30,9 +36,9 @@ kotlin {
|
||||
// targets (macosX64/Arm64, iosX64/Arm64/SimulatorArm64, linuxArm64),
|
||||
// так что KMP-метаданные корректно резолвятся для всех таргетов.
|
||||
|
||||
// ksqlite ещё не опубликован в Maven Central — только в локальном
|
||||
// caffeine-репо. Версия пока 0.1.0-SNAPSHOT (CI fallback из README).
|
||||
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)
|
||||
|
||||
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(
|
||||
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(
|
||||
ErrorPayload.serializer(),
|
||||
@@ -59,7 +59,7 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
}
|
||||
"tool_result" -> {
|
||||
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" -> {
|
||||
val p = Json.decodeFromString(ErrorPayload.serializer(), payload)
|
||||
@@ -72,8 +72,18 @@ internal fun SQLiteResultSet.toMessageRecord(json: Json): MessageRecord {
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
|
||||
|
||||
/**
|
||||
* Тулрезалт-сериализация для SQLite. [toolName] денормализован из
|
||||
* соответствующего `ToolCall.name` для упрощения UI (нет нужды в
|
||||
* локальной `Map<id, name>`). Nullable с дефолтом — старые записи
|
||||
* без поля десериализуются как `null`.
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ResultPayload(val toolCallId: String, val result: String?)
|
||||
internal data class ResultPayload(
|
||||
val toolCallId: String,
|
||||
val toolName: String? = null,
|
||||
val result: String?,
|
||||
)
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
internal data class ErrorPayload(val message: String, val code: String?)
|
||||
|
||||
Reference in New Issue
Block a user