feat(agentik-cli): one-shot subcommand CLI; client: streaming SSE via prepareGet
ci / JVM build + tests (push) Failing after 2m11s

- :agentik-cli переписан с REPL на one-shot subcommands:
  conv-ls / conv-new / conv-show / conv-delete / conv-rename /
  msgs / send / interrupt / info. Аргумент-парсер — kotlinx.cli 0.3.6
  (clikt 5.x отвергнут из-за upstream-бага duplicate symbol
  selfAndAncestors между clikt и clikt-mordant, issue #598).
- :client: events() переведён с httpClient.get() на
  prepareGet()+execute{} — get() дожидается полного тела, а SSE
  не закрывается никогда, поэтому подписка висела вечно. (Это
  же объясняет, почему TUI agent.events() фактически был
  нерабочим на реальном сервере.)
- :client KMP-конверсия (jvm + 5 desktop-native) уже была в
  коммите 9d826a4, здесь она просто подтверждена в статусе
  green по всем таргетам.
- REPL-инфраструктура (CliPlatform, EventRenderer, Main,
  SessionRepository, SlashCommand + 3 теста) удалена.
- agentik-cli/README переписан под subcommand-формат,
  root README обновлён (убран дубликат строки, agentik-tui
  убран из 'Запускаемые модули').

Smoke (на 192.168.76.166): info / conv-ls / conv-new /
conv-rename / conv-show / conv-delete / msgs / send
(стримит response-events до event End) / interrupt
(выводит event Interrupted).
This commit is contained in:
2026-09-17 23:05:25 +03:00
parent ee0b9d8341
commit b5b21d146a
37 changed files with 595 additions and 1427 deletions
+43 -27
View File
@@ -1,34 +1,50 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
jvmToolchain(21)
// Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
// :client не имеет смысла на iOS, а :agentik-cli использует :client и тоже
// без iOS. См. agentik-cli/build.gradle.kts.
jvm()
listOf(
macosX64(),
macosArm64(),
linuxX64(),
linuxArm64(),
mingwX64(),
)
sourceSets {
commonMain.dependencies {
api(project(":proto"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.sse)
}
jvmTest.dependencies {
implementation("junit:junit:4.13.2")
}
}
}
dependencies {
implementation(project(":proto"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation("junit:junit:4.13.2")
testImplementation(libs.ktor.server.core)
testImplementation(libs.ktor.server.cio)
testImplementation(libs.ktor.server.test.host)
testImplementation(libs.ktor.server.sse)
testImplementation(libs.ktor.client.content.negotiation)
// :client — это библиотека, не executable. Native-бинари объявляются
// в :agentik-cli (он зависит от :client и реально предоставляет main).
}
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
@@ -66,15 +67,15 @@ internal class AgentClient(
}
override fun events(after: Instant): Flow<AgentEvent> = flow {
val response = httpClient.get("$agentUrl/events?after=$after") {
noSseReadTimeout()
}
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
}
}
}
@@ -0,0 +1,46 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import pw.binom.agentik.proto.Agent
/**
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
* (модуль `:server`).
*
* ```
* val client = AgentikAgent(
* id = "my-agent",
* baseUrl = "http://localhost:8080/agentik",
* )
* val conv = client.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi")))
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
* ```
*
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
* агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига).
*
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
*/
fun AgentikAgent(
id: String,
baseUrl: String,
httpClient: HttpClient = defaultAgentikHttpClient(),
): 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].
*/
@@ -6,6 +6,7 @@ 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
@@ -72,15 +73,18 @@ internal class ConversationClient(
}
override fun events(after: Instant): Flow<Event> = flow {
val response = httpClient.get("$convUrl/events?after=$after") {
noSseReadTimeout()
}
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
// 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))
}
}
}
@@ -0,0 +1,18 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
/**
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
*
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
engine { requestTimeout = 0 }
install(ContentNegotiation) { json(agentikJson) }
}
@@ -1,56 +0,0 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import pw.binom.agentik.proto.Agent
/**
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
* (модуль `:server`).
*
* ```
* val client = AgentikAgent(
* id = "my-agent",
* baseUrl = "http://localhost:8080/agentik",
* )
* val conv = client.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi")))
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
* ```
*
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
* агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига).
*
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
*/
fun AgentikAgent(
id: String,
baseUrl: String,
httpClient: HttpClient = defaultAgentikHttpClient(),
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
/**
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
* (см. [readSse]) живёт в общем коде и плагина не требует.
*
* Engine config: `requestTimeout = 0` отключает встроенный request-таймаут CIO
* (по умолчанию 15 с — наш кастомный SSE-ридер не маркирует запрос как
* `SSEClientContent`, и `getRequestTimeout` для движка вернул бы 15000 мс).
* Сами SSE-запросы в `ConversationClient.events`/`AgentClient.events` и так
* уже выставляют `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]),
* но `engine { requestTimeout = 0 }` — defense-in-depth: если кто-то
* использует свой HttpClient через `AgentikAgent(..., httpClient = ...)` без
* capability, фоновый стрим на нашем URL тоже не убьётся по 15-секундному
* таймеру.
*
* Connect/socket таймауты оставлены дефолтными (5 с / бесконечность).
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
engine { requestTimeout = 0 }
install(ContentNegotiation) { json(agentikJson) }
}