From f946186ef548eb8c52e89a420a5b8742aec76d0c Mon Sep 17 00:00:00 2001 From: subochev Date: Mon, 21 Sep 2026 03:53:35 +0300 Subject: [PATCH] feat(client): refactor `AgentikAgent` to manage its own `HttpClient` - `AgentikAgent` now accepts `engineFactory` and an optional `token` to create an internal `HttpClient`, handling all configuration (JSON, Bearer). - Removed `applyAgentikDefaults` and replaced it with `agentikHttpClient` for `HttpClient` creation with consistent settings. - Updated `Agent` to implement `AutoCloseable`, ensuring proper resource closure with `agent.close()`. - Adjusted tests, docs, and examples to align with the new `AgentikAgent` API. --- client/README.md | 69 ++++++++++--------- .../pw/binom/agentik/client/AgentClient.kt | 31 +++------ .../pw/binom/agentik/client/AgentikAgent.kt | 47 +++++++------ .../binom/agentik/client/HttpClientFactory.kt | 36 ++++------ .../binom/agentik/client/BearerHeaderTest.kt | 4 +- .../kotlin/pw/binom/agentik/proto/Agent.kt | 9 ++- .../binom/agentik/server/BearerTokenTest.kt | 1 + 7 files changed, 95 insertions(+), 102 deletions(-) diff --git a/client/README.md b/client/README.md index 87f7cd1..cd0f517 100644 --- a/client/README.md +++ b/client/README.md @@ -6,64 +6,63 @@ ## Что есть -- `AgentikAgent(id, baseUrl, httpClient)` — entry-point. Возвращает `Agent` - (тот же интерфейс, что в `:proto`). +- `AgentikAgent(id, baseUrl, engineFactory, token?)` — entry-point. Возвращает + `Agent` (тот же интерфейс, что в `:proto`). HttpClient создаётся внутри + из переданной `engineFactory` (`CIO`, `OkHttp`, `Darwin`). - `Agent`: `createConversation` / `getConversation` / `getConversations` / - `deleteConversation` / `journal` / `outbox`. + `deleteConversation` / `journal` / `outbox` / `close`. - `Conversation`: `send(content, context?)` / `events(after)` (SSE `Flow`) / `getMessages(after, offset, limit)` / `rename` / `interrupt` / `close`. - `HttpJournalStore` — `list(convId, after, offset, limit)` → `List` со всеми типами записей (User/Assistant/ToolCall/ToolResult/Error + tokens). - `HttpEventStore` — `events` / `agentEvents` / `conversationEvents` (SSE). -`HttpClient` создаётся снаружи (выбор движка — на тебе: CIO, OkHttp, -Darwin). Конфигурация (JSON + Bearer-токен) — через `applyAgentikDefaults`. +`Agent` — `AutoCloseable`; `agent.close()` закрывает HttpClient. Не нужно +вручную создавать `HttpClient` и накатывать на него JSON/Bearer-плагины. ## Подключение ```kotlin // build.gradle.kts -kotlin { - sourceSets.commonMain.dependencies { - api("pw.binom.agentik:client:0.1.0") - // Опционально — только если будешь использовать `InMemoryJournalStore` - // как клиентский кэш. Свой `MutableJournalStore` — не нужен. - api("pw.binom.agentik:journal-inmemory:0.1.0") - } +dependencies { + api("pw.binom.agentik:client:0.1.0") + // Движок — на твой выбор (один из): + implementation("io.ktor:ktor-client-cio:3.x") // JVM/Native + implementation("io.ktor:ktor-client-okhttp:3.x") // JVM + implementation("io.ktor:ktor-client-darwin:3.x") // iOS/macOS + // Опционально — только если будешь использовать `InMemoryJournalStore` + // как клиентский кэш. Свой `MutableJournalStore` — не нужен. + api("pw.binom.agentik:journal-inmemory:0.1.0") } ``` ## Быстрый старт: свой клиент за 5 минут -Один self-contained пример: создаём HTTP-клиент, открываем диалог, +Один self-contained пример: создаём агента, открываем диалог, отправляем сообщение, печатаем streaming-ответ. ```kotlin import pw.binom.agentik.client.AgentikAgent -import pw.binom.agentik.client.applyAgentikDefaults import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Event -import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import kotlinx.coroutines.runBlocking import kotlin.time.Clock fun main() = runBlocking { - // 1. HTTP-клиент. Движок выбираешь сам (CIO/OkHttp/Darwin). - val http = HttpClient(CIO) { applyAgentikDefaults(token = "s3cret") } - - // 2. Agent — обёртка над :server фасадом. + // 1. Agent — обёртка над :server фасадом. HttpClient создаётся внутри. val agent = AgentikAgent( id = "my-client", baseUrl = "http://localhost:8080/agentik", - httpClient = http, + engineFactory = CIO, + token = "s3cret", // или null, если не нужен ) - // 3. Открыть диалог, отправить сообщение. + // 2. Открыть диалог, отправить сообщение. val conv = agent.createConversation(temp = false) conv.send(listOf(Content.Text("Привет"))) - // 4. Собирать streaming-ответ. + // 3. Собирать streaming-ответ. conv.events(after = Clock.System.now()).collect { ev -> when (ev) { is Event.StartResponse -> println("[start]") @@ -74,15 +73,18 @@ fun main() = runBlocking { } } - // 5. Чистый shutdown. + // 4. Чистый shutdown. conv.close() - http.close() + agent.close() } ``` **Это весь клиент.** `:server` сам хранит историю, контекст, события. Ты только получаешь типизированный `Flow` и рендеришь как хочешь. +`HttpClient`, `applyAgentikDefaults`, выбор engine'а — всё скрыто +внутри `AgentikAgent`. Один вызов — один готовый `Agent`. + ### Добавить локальный кэш истории (ещё 4 строки) ```kotlin @@ -117,14 +119,17 @@ history.forEach { rec -> ### Что вообще не нужно писать самому -- HTTP-сериализация `Event`/`Message` — `applyAgentikDefaults` регистрирует +- HTTP-сериализация `Event`/`Message` — `agentikHttpClient` регистрирует `agentikJson` и `InstantSerializer`. - SSE-парсер — `readSse()` внутри `:client`. - Cursor-менеджмент для `listFlow` — дефолтная имплементация в `JournalStore.listFlow` сама пагинирует. - Lifecycle подписок на `events()` — `Conversation.close()` отменяет SSE-job. -- Bearer-токен в каждом запросе — `applyAgentikDefaults(token = ...)` инжектит - один раз на весь `HttpClient`. +- HTTP-клиент и Bearer — `AgentikAgent` создаёт `HttpClient(engineFactory)` + с Bearer'ом из `token=` под капотом; `agent.close()` его закрывает. +- Движковые настройки (requestTimeout и пр.) — `HttpClient(engineFactory) { ... }` + создаётся здесь; для нестандартных движковых настроек используй + `agentikHttpClient(engineFactory, token)` напрямую (он экспортирован). ### Что нужно написать самому @@ -138,17 +143,14 @@ history.forEach { rec -> ```kotlin import pw.binom.agentik.client.AgentikAgent -import pw.binom.agentik.client.applyAgentikDefaults import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Event -import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO -val http = HttpClient(CIO) { applyAgentikDefaults(token = "s3cret") } val agent = AgentikAgent( id = "agentik", baseUrl = "http://localhost:8080/agentik", - httpClient = http, + engineFactory = CIO, ) val conv = agent.createConversation(temp = false) @@ -309,8 +311,9 @@ UI-обновление списка — отдельная задача, реш - **Персистентность кэша** — `InMemoryJournalStore` хранит в RAM. Для диска пиши свой `MutableJournalStore` (см. `KsqliteJournalStore` в `:journal-ksqlite` как образец). -- **Авторизация** — `applyAgentikDefaults(token = "...")` для Bearer; - для OAuth/что-то ещё — конфигурируй `HttpClient` сам. +- **Нестандартные движковые настройки** — для `requestTimeout`, + прокси и т.п. используй `agentikHttpClient(engineFactory, token)` + напрямую. ## Тесты diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt index 8b3ce5a..cea1f79 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt @@ -7,6 +7,7 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType @@ -19,36 +20,20 @@ import pw.binom.agentik.proto.Conversation /** * HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`. * - * Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend - * (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа - * POST `/conversations`. Используем `runBlocking` — это одноразовая - * операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам - * решает, что делать. + * HttpClient создаётся внутри из переданного engine и закрывается в [close]. * * **Storage handles** ([journal], [outbox]) — read-only views на серверные - * хранилища. [outbox] уже реализован ([HttpEventStore]); [journal] — - * заглушка, потому что соответствующий HTTP endpoint'ы (`/journal/...`) - * ещё не выставлены на стороне `:server`. После их добавления подменить - * `error(...)` на `HttpJournalStore(...)`. + * хранилища. */ internal class AgentClient( - private val httpClient: HttpClient, - private val baseUrl: String, override val id: String, + private val baseUrl: String, + private val httpClient: HttpClient, ) : Agent { private val agentUrl: String = baseUrl.trimEnd('/') - /** - * Единый канал событий (lifecycle + per-conversation). Под капотом — - * [HttpEventStore]: каждый метод бьёт свой URL (см. KDoc). - */ override val outbox: OutboxStore = HttpEventStore(httpClient = httpClient, baseUrl = agentUrl) - - /** - * HTTP-фасад для journal: ходит в `:server`'s `GET /journal/conversations/{id}/messages`. - * См. [HttpJournalStore] и [pw.binom.agentik.server.journalRoutes]. - */ override val journal: JournalStore = HttpJournalStore(httpClient = httpClient, baseUrl = agentUrl) override fun createConversation(temp: Boolean): Conversation = @@ -68,7 +53,7 @@ internal class AgentClient( } override suspend fun deleteConversation(id: String): Boolean { - val response = httpClient.delete("$agentUrl/conversations/$id") + val response: HttpResponse = httpClient.delete("$agentUrl/conversations/$id") return response.status == HttpStatusCode.NoContent } @@ -79,4 +64,8 @@ internal class AgentClient( }.body>() return snapshots.map { ConversationClient(httpClient, agentUrl, it) } } + + override fun close() { + httpClient.close() + } } diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt index ebe6a36..3453ffa 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt @@ -1,38 +1,43 @@ package pw.binom.agentik.client -import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngineFactory import pw.binom.agentik.proto.Agent /** - * Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent` - * (модуль `:server`). + * Создаёт [Agent], который ходит в HTTP-фасад `agentikAgent` (модуль `:server`). + * + * Принимает [engineFactory] — `HttpClientEngineFactory<*>` (`CIO`, `OkHttp`, + * `Darwin`, ...). Внутри сам создаёт `HttpClient`, накатывает JSON-конфиг + * [agentikJson] и опциональный Bearer [token]. Никакого `applyAgentikDefaults` + * снаружи — всё под капотом. * * ``` - * val http = HttpClient(engine) { - * applyAgentikDefaults(token = "s3cret") - * // engine — на выбор потребителя (CIO, OkHttp, Darwin, ...); - * // движковые настройки (requestTimeout и пр.) — там же - * } - * val client = AgentikAgent( - * id = "my-agent", + * val agent = AgentikAgent( + * id = "my-client", * baseUrl = "http://localhost:8080/agentik", - * httpClient = http, + * engineFactory = CIO, + * token = "s3cret", * ) - * val conv = client.createConversation(temp = false) + * val conv = agent.createConversation(temp = false) * conv.send(listOf(Content.Text("hi"))) - * conv.events(Instant.DISTANT_PAST).collect { ev -> ... } + * conv.events(Instant.DISTANT_PAST).collect { ... } + * agent.close() // закрывает HttpClient * ``` * - * [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность - * агента не знает, поэтому клиент должен её знать сам (или взять из - * конфига). + * [id] пробрасывается в `Agent.id` — сервер про идентичность агента не знает, + * поэтому клиент должен её знать сам (или взять из конфига). * - * `:client` НЕ выбирает движок: [HttpClient] (с уже созданным engine'ом) - * приходит снаружи. К блоку конфигурации применяется [applyAgentikDefaults] - * (JSON + опциональный Bearer-токен). + * **Lifecycle**: [Agent] — `AutoCloseable`. `agent.close()` закрывает + * HttpClient (идемпотентно). После этого `createConversation` / + * `getConversation` etc. не определены. */ fun AgentikAgent( id: String, baseUrl: String, - httpClient: HttpClient, -): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) \ No newline at end of file + engineFactory: HttpClientEngineFactory<*>, + token: String? = null, +): Agent = AgentClient( + id = id, + baseUrl = baseUrl, + httpClient = agentikHttpClient(engineFactory = engineFactory, token = token), +) diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt index d656f1e..62d40fb 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt @@ -1,7 +1,7 @@ package pw.binom.agentik.client import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.header @@ -9,32 +9,20 @@ import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json /** - * Общая конфигурация HTTP-клиента agentik — платформо-независимая часть. + * Создаёт [HttpClient] поверх [engineFactory] с конфигурацией agentik. * - * `:client` НЕ выбирает движок: его приносит потребитель. Здесь живёт только то, - * без чего клиент несовместим с `/agentik`: - * - JSON-конфиг [agentikJson] (обязан совпадать с серверным); - * - при заданном [token] — `Authorization: Bearer ` на ВСЕ запросы - * через [DefaultRequest] (накрывает 10 REST-вызовов и оба SSE-потока; - * заголовок живёт на клиенте, а не в отдельных запросах). + * Внутренний helper для [AgentikAgent]. Потребителю `:client` обычно + * не нужен — он передаёт engine в [AgentikAgent] и получает готовый + * [pw.binom.agentik.proto.Agent] с уже закрытым HttpClient'ом + * на [pw.binom.agentik.proto.Agent.close]. * - * `null` — авторизация выключена, заголовок не отправляется. - * - * Применяется к уже сконструированному [HttpClient]: - * ``` - * val engine = HttpClientEngineFactory().create() // потребитель выбирает движок - * val http = HttpClient(engine) { - * applyAgentikDefaults(token) - * // движковые настройки (requestTimeout и пр.) — потребитель знает свой движок - * } - * ``` - * - * Раньше `:client` экспортировал фабрику `agentikHttpClient(engineFactory, ...)`, - * но она навязывала тип-параметр `HttpClientEngineFactory` и неявно тянула за - * собой конкретный движок в виде примера в KDoc. Сейчас фабрики нет — потребитель - * сам создаёт [HttpClient] и накатывает на блок конфигурации [applyAgentikDefaults]. + * Экспортируется для случаев, когда нужен прямой доступ к `HttpClient` + * (например, дополнительные нестандартные запросы в обход `Agent` API). */ -fun HttpClientConfig<*>.applyAgentikDefaults(token: String? = null) { +fun agentikHttpClient( + engineFactory: HttpClientEngineFactory<*>, + token: String? = null, +): HttpClient = HttpClient(engineFactory) { install(ContentNegotiation) { json(agentikJson) } if (token != null) { install(DefaultRequest) { diff --git a/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt b/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt index 8e070da..1a4e53d 100644 --- a/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt +++ b/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt @@ -21,7 +21,7 @@ import kotlin.test.Test import kotlin.test.assertEquals /** - * Тесты клиентской части: [applyAgentikDefaults] с заданным `token` прикладывает + * Тесты клиентской части: [agentikHttpClient] с заданным `token` прикладывает * `Authorization: Bearer ` ко всем запросам через плагин `DefaultRequest`, * без токена — заголовок не отправляется. * @@ -50,7 +50,7 @@ class BearerHeaderTest { } private fun clientWith(token: String?): HttpClient = - HttpClient(CIO) { applyAgentikDefaults(token) } + agentikHttpClient(engineFactory = CIO, token = token) private suspend fun startServer(): Pair, Int> { val server = embeddedServer(ServerCIO, port = 0) { diff --git a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt index 9e0c2ab..b7501e4 100644 --- a/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt +++ b/proto/src/commonMain/kotlin/pw/binom/agentik/proto/Agent.kt @@ -19,11 +19,18 @@ import kotlin.time.Instant * `outbox.agentEvents(after)`. Это даёт единый путь для всех read-операций * по хранилищу и убирает дублирование между протоколом и хранилищем. */ -public interface Agent { +interface Agent : AutoCloseable { /** Идентификатор агента. */ val id: String + /** + * Освобождает ресурсы агента (HTTP-клиент, сетевые handles, подписки). + * После [close] вызовы [createConversation] / [getConversation] и т.п. + * не определены. Idempotent. + */ + override fun close() + /** * Append-only audit log всех сообщений диалогов (read-only view). * diff --git a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt index 4927e08..944e483 100644 --- a/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt +++ b/server/src/commonTest/kotlin/pw/binom/agentik/server/BearerTokenTest.kt @@ -57,6 +57,7 @@ class BearerTokenTest { override suspend fun getConversation(id: String): Conversation? = null override suspend fun deleteConversation(id: String): Boolean = false override suspend fun getConversations(offset: Int, limit: Int): List = emptyList() + override fun close() {} } private suspend fun startServer(token: String?): Pair, Int> {