diff --git a/agentik-cli/build.gradle.kts b/agentik-cli/build.gradle.kts index 40cb8a1..a5ef456 100644 --- a/agentik-cli/build.gradle.kts +++ b/agentik-cli/build.gradle.kts @@ -41,6 +41,7 @@ kotlin { implementation(libs.kotlinx.cli) implementation(libs.kotlinx.coroutines.core) + implementation(libs.ktor.client.cio) } // :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения): // весь код, включая platformEnv, лежит в commonMain. diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikHttpClient.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikHttpClient.kt new file mode 100644 index 0000000..07d4ff3 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikHttpClient.kt @@ -0,0 +1,23 @@ +package pw.binom.agentik.cli + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import pw.binom.agentik.client.agentikHttpClient + +/** + * HTTP-клиент CLI: движок CIO + конфигурация agentik. + * + * Движок живёт здесь, а не в `:client`: библиотека не выбирает транспорт за + * потребителя. Таргеты `:agentik-cli` (jvm + linuxX64/macosX64/macosArm64/mingwX64) + * покрываются CIO. + * + * `requestTimeout = 0` — отключение встроенного request-таймаута CIO; + * defense-in-depth против обрыва долгих SSE-idle (основная защита — + * `noSseReadTimeout` в `:client`). + * + * [token] = `null` — авторизация выключена. + */ +internal fun defaultCliHttpClient(token: String? = null): HttpClient = + agentikHttpClient(engineFactory = CIO, token = token) { + engine { requestTimeout = 0 } + } \ No newline at end of file diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvDeleteSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvDeleteSubcommand.kt index 3842210..5616c66 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvDeleteSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvDeleteSubcommand.kt @@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands import kotlinx.cli.ArgType import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") { val id by argument(ArgType.String, description = "ID диалога") override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val ok = agent.deleteConversation(id) if (ok) println("deleted: $id") else println("conversation not found: $id") } diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvLsSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvLsSubcommand.kt index 8da347d..d8f0a6b 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvLsSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvLsSubcommand.kt @@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands import kotlinx.cli.ArgType import kotlinx.cli.default import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.proto.Agent @@ -10,7 +11,7 @@ class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов а val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE) override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE)) if (convs.isEmpty()) { println("(no conversations)") diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvNewSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvNewSubcommand.kt index 979237c..01b2c29 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvNewSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvNewSubcommand.kt @@ -3,13 +3,14 @@ package pw.binom.agentik.cli.commands import kotlinx.cli.ArgType import kotlinx.cli.default import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") { val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false) override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val conv = agent.createConversation(temp = temp) println(conv.id) } diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvRenameSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvRenameSubcommand.kt index 537bb27..1cfef72 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvRenameSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvRenameSubcommand.kt @@ -2,6 +2,7 @@ package pw.binom.agentik.cli.commands import kotlinx.cli.ArgType import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") { @@ -9,7 +10,7 @@ class ConvRenameSubcommand : ConvSubcommand("rename", "Переименоват val title by argument(ArgType.String, description = "Новое название") override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val conv = agent.getConversation(id) ?: run { println("conversation not found: $id") return@runBlocking diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvShowSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvShowSubcommand.kt index 83830f2..68daef7 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvShowSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvShowSubcommand.kt @@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands import kotlinx.cli.ArgType import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") { val id by argument(ArgType.String, description = "ID диалога") override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val conv = agent.getConversation(id) ?: run { println("conversation not found: $id") return@runBlocking diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InterruptSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InterruptSubcommand.kt index 2424c58..706dc40 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InterruptSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InterruptSubcommand.kt @@ -2,13 +2,14 @@ package pw.binom.agentik.cli.commands import kotlinx.cli.ArgType import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") { val id by argument(ArgType.String, description = "ID диалога") override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val conv = agent.getConversation(id) ?: run { println("conversation not found: $id") return@runBlocking diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/MsgsSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/MsgsSubcommand.kt index 8ffbed2..d0120c0 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/MsgsSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/MsgsSubcommand.kt @@ -3,6 +3,7 @@ package pw.binom.agentik.cli.commands import kotlinx.cli.ArgType import kotlinx.cli.default import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Message @@ -13,7 +14,7 @@ class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообще val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100) override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val conv = agent.getConversation(id) ?: run { println("conversation not found: $id") return@runBlocking diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/SendSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/SendSubcommand.kt index 301034e..ba52b09 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/SendSubcommand.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/SendSubcommand.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.cli.defaultCliHttpClient import pw.binom.agentik.client.AgentikAgent import pw.binom.agentik.proto.Content import pw.binom.agentik.proto.Event @@ -17,7 +18,7 @@ class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход val text by argument(ArgType.String, description = "Текст хода (все позиционные после склеиваются пробелом)").vararg() override fun execute() = kotlinx.coroutines.runBlocking { - val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl, httpClient = defaultCliHttpClient()) val conv = agent.getConversation(id) ?: run { println("conversation not found: $id") return@runBlocking diff --git a/client/build.gradle.kts b/client/build.gradle.kts index bf28513..6729b3f 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -22,8 +22,7 @@ kotlin { commonMain.dependencies { api(project(":proto")) - implementation(libs.ktor.client.core) - implementation(libs.ktor.client.cio) + api(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) @@ -37,6 +36,7 @@ kotlin { implementation(libs.ktor.server.core) implementation(libs.ktor.server.test.host) implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.cio) implementation(libs.ktor.server.cio) implementation(libs.ktor.server.sse) } 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 ecb9540..412aeb1 100644 --- a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt @@ -8,9 +8,11 @@ import pw.binom.agentik.proto.Agent * (модуль `:server`). * * ``` + * val http = HttpClient(CIO) { applyAgentikDefaults(token = "s3cret") } * val client = AgentikAgent( * id = "my-agent", * baseUrl = "http://localhost:8080/agentik", + * httpClient = http, * ) * val conv = client.createConversation(temp = false) * conv.send(listOf(Content.Text("hi"))) @@ -21,27 +23,13 @@ import pw.binom.agentik.proto.Agent * агента не знает, поэтому клиент должен её знать сам (или взять из * конфига). * - * [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый - * движок: CIO на JVM, libcurl на desktop-native). Можно передать свой. + * Клиент приходит снаружи: `:client` не выбирает движок. Собрать [HttpClient] + * можно через [agentikHttpClient] (фабрика движка + опциональные движковые + * настройки) или вручную, применив к блоку конфигурации [applyAgentikDefaults] + * (JSON + опциональный Bearer-токен). */ fun AgentikAgent( id: String, baseUrl: String, - token: String? = null, - httpClient: HttpClient = defaultAgentikHttpClient(token), -): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) - -/** - * Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse]) - * живёт в общем коде и плагина `SSEClientContent` не требует. - * - * **Платформы:** - * - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный - * 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует - * для долгих idle-стримов). Defense-in-depth: SSE-запросы в - * `ConversationClient.events`/`AgentClient.events` уже ставят - * `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]). - * - * Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw). - * Реализация — в [HttpClientFactory.kt]. - */ + httpClient: HttpClient, +): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) \ No newline at end of file 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 295da32..269a6a9 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,9 @@ package pw.binom.agentik.client import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.HttpClientEngineConfig +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,19 +11,26 @@ import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json /** - * Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli). - * CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64. + * Общая конфигурация HTTP-клиента agentik — платформо-независимая часть. * - * `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE: - * основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]). + * `:client` НЕ выбирает движок: его приносит потребитель. Здесь живёт только то, + * без чего клиент несовместим с `/agentik`: + * - JSON-конфиг [agentikJson] (обязан совпадать с серверным); + * - при заданном [token] — `Authorization: Bearer ` на ВСЕ запросы + * через [DefaultRequest] (накрывает 10 REST-вызовов и оба SSE-потока; + * заголовок живёт на клиенте, а не в отдельных запросах). * - * При заданном [token] на ВСЕ запросы клиента навешивается - * `Authorization: Bearer ` через плагин [DefaultRequest]. Это накрывает - * все 10 REST-вызовов и оба SSE-потока сразу — заголовок живёт на HTTP-клиенте, - * а не в отдельных запросах. + * `null` — авторизация выключена, заголовок не отправляется. + * + * Потребитель, знающий свой движок, добавляет к этому движковые настройки, напр.: + * ``` + * val http = HttpClient(CIO) { + * engine { requestTimeout = 0 } // CIO-специфика, живёт у потребителя + * applyAgentikDefaults(token) + * } + * ``` */ -fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO) { - engine { requestTimeout = 0 } +fun HttpClientConfig<*>.applyAgentikDefaults(token: String? = null) { install(ContentNegotiation) { json(agentikJson) } if (token != null) { install(DefaultRequest) { @@ -29,3 +38,23 @@ fun defaultAgentikHttpClient(token: String? = null): HttpClient = HttpClient(CIO } } } + +/** + * Создаёт [HttpClient] из фабрики движка потребителя и сразу применяет к нему + * конфигурацию agentik ([applyAgentikDefaults]). + * + * Это точка, где `:client` НЕ привязан к реализации транспорта: [engineFactory] + * выбирает потребитель (CIO, OkHttp, Darwin, …), а `:client` только конфигурирует + * созданный клиент. + * + * [configure] — опциональный последний штрих потребителя (движковые настройки: + * таймауты, прокси, логирование). Вызывается ПОСЛЕ [applyAgentikDefaults]. + */ +fun agentikHttpClient( + engineFactory: HttpClientEngineFactory, + token: String? = null, + configure: (HttpClientConfig.() -> Unit)? = null, +): HttpClient = HttpClient(engineFactory) { + applyAgentikDefaults(token) + configure?.invoke(this) +} \ No newline at end of file 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 c31e9f0..8e070da 100644 --- a/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt +++ b/client/src/commonTest/kotlin/pw/binom/agentik/client/BearerHeaderTest.kt @@ -1,5 +1,7 @@ package pw.binom.agentik.client +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType @@ -19,7 +21,7 @@ import kotlin.test.Test import kotlin.test.assertEquals /** - * Тесты клиентской части: [defaultAgentikHttpClient] с заданным `token` прикладывает + * Тесты клиентской части: [applyAgentikDefaults] с заданным `token` прикладывает * `Authorization: Bearer ` ко всем запросам через плагин `DefaultRequest`, * без токена — заголовок не отправляется. * @@ -47,6 +49,9 @@ class BearerHeaderTest { var token: String? = null } + private fun clientWith(token: String?): HttpClient = + HttpClient(CIO) { applyAgentikDefaults(token) } + private suspend fun startServer(): Pair, Int> { val server = embeddedServer(ServerCIO, port = 0) { routing { @@ -66,7 +71,7 @@ class BearerHeaderTest { fun clientWithTokenAttachesBearerHeader() = runBlocking { val (server, port) = startServer() try { - val client = defaultAgentikHttpClient("secret") + val client = clientWith("secret") val resp = client.get("http://127.0.0.1:$port/agentik/conversations") assertEquals(HttpStatusCode.OK, resp.status) assertEquals("[]", resp.bodyAsText()) @@ -79,7 +84,7 @@ class BearerHeaderTest { fun clientWithoutTokenGets401(): Unit = runBlocking { val (server, port) = startServer() try { - val client = defaultAgentikHttpClient(null) + val client = clientWith(null) val resp = client.get("http://127.0.0.1:$port/agentik/conversations") assertEquals(HttpStatusCode.Unauthorized, resp.status) } finally { @@ -91,7 +96,7 @@ class BearerHeaderTest { fun clientWithWrongTokenGets401(): Unit = runBlocking { val (server, port) = startServer() try { - val client = defaultAgentikHttpClient("wrong") + val client = clientWith("wrong") val resp = client.get("http://127.0.0.1:$port/agentik/conversations") assertEquals(HttpStatusCode.Unauthorized, resp.status) } finally {