diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a839867..49584d9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -65,15 +65,15 @@ jobs: ./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)" + test -f agentik-cli/build/libs/agentik-cli-*-all.jar \ + && echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)" - name: Upload shadowJars uses: actions/upload-artifact@v4 with: name: agentik-jars path: | - standalone/build/libs/standalone-all.jar - agentik-cli/build/libs/agentik-cli-all.jar + standalone/build/libs/standalone-*-all.jar + agentik-cli/build/libs/agentik-cli-*-all.jar if-no-files-found: error retention-days: 7 diff --git a/README.md b/README.md index 68fec5d..25d4c88 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ agentik/ ├── storage-inmemory/ in-memory реализация для тестов и Android ├── storage-sqlite/ SQLite реализация для production ├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget -├── agentik-cli/ JVM REPL-клиент (JLine) к /agentik -├── agentik-cli/ JVM + 8 native CLI-клиент к /agentik (REPL) +├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik ├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17 └── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками ``` @@ -71,7 +70,7 @@ java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar ```bash # CLI -java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-all.jar +java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help # curl curl http://localhost:8080/health @@ -81,8 +80,7 @@ curl http://localhost:8080/health - Запускаемые: - [`:standalone`](standalone/README.md) — single-jar HTTP-сервер. - - [`:agentik-cli`](agentik-cli/README.md) — REPL-клиент (JLine). - - [`:agentik-tui`](agentik-tui/README.md) — Compose-for-Mosaic TUI. + - [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli). - Библиотеки (контракты и реализации): - [`:proto`](proto/README.md) — stateful KMP-протокол. - [`:server`](server/README.md) — HTTP/SSE фасад `:proto`. diff --git a/agentik-cli/README.md b/agentik-cli/README.md index 6d43b53..e110b10 100644 --- a/agentik-cli/README.md +++ b/agentik-cli/README.md @@ -2,83 +2,122 @@ ## Что это -JVM-only REPL-клиент к серверу `:standalone` через `:client` -над HTTP+SSE: +JVM-only **one-shot subcommand CLI** к серверу `:standalone` через +`:client` по HTTP+SSE. Один вызов — одна команда: стримы ответа +`send` идёт в stdout построчно, никакого embedded-REPL. -- Нативный REPL с JLine (стрелки влево/вправо/вверх, история, - Ctrl-D/E). -- Подписка на live-стрим событий агента. -- Slash-команды: `/new /list /switch /rename /rm /interrupt /history - /pwd /help /exit /quit`. -- Persistent session id в `~/.agentik/cli-state.json`. +Решает: быстрый способ дёрнуть агента из shell-скрипта или руками, +не поднимая отдельную TUI-сессию. -Решает: быстрый способ проверить агента руками из терминала. -Используется в CI-смоук-тестах и для daily-driver. +## Подкоманды + +``` +agentik-cli [options] + +Subcommands: + conv-ls Список диалогов агента + conv-new Создать диалог; печатает id + conv-show Метаданные диалога + conv-delete Удалить диалог + conv-rename Переименовать диалог + msgs Показать сообщения диалога + send Отправить user-ход и стримить ответ + interrupt Прервать текущий ход диалога + info Показать server URL и agent id +``` + +`--server URL` и `--id ID` (env: `AGENTIK_SERVER`, `AGENTIK_AGENT_ID`) +задаются **после** имени subcommand'а — kotlinx.cli не шарит опции +родителя в subcommand. Пример: + +```bash +agentik-cli conv-ls --server http://192.168.76.166:8080/agentik +agentik-cli conv-new --server http://localhost:8080/agentik +agentik-cli send --server http://localhost:8080/agentik conv-abc "привет" +``` ## Как запустить -### Требования - -- JVM 21+ (на машине должна быть JAVA_HOME или `java` в PATH). -- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`). - -### Запуск из готового fatjar +### Из готового fatjar ```bash -java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-all.jar \ - --server http://192.168.76.166:8080/agentik +java --enable-native-access=ALL-UNNAMED \ + -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help ``` -### Запуск через Gradle (dev) +### Через Gradle ```bash -./gradlew :agentik-cli:run --args="--server http://localhost:8080/agentik" +./gradlew :agentik-cli:shadowJar +java -jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar info ``` -## Параметры CLI +## Примеры -| Флаг | ENV | Что делает | -|---|---|---| -| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) | -| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) | -| `--no-history` | — | Не восстанавливать последнюю диалог после запуска | -| `--help` | — | Показывает help и выходит | +```bash +# Список диалогов (таблица) +agentik-cli conv-ls --server http://localhost:8080/agentik -## Slash-команды (внутри REPL) +# Создать диалог +ID=$(agentik-cli conv-new --server http://localhost:8080/agentik) +echo "new conv: $ID" -| Команда | Синонимы | Что делает | -|---|---|---| -| `/help` | | Показывает help | -| `/new [title]` | | Создать диалог | -| `/list` | `/ls` | Список диалогов | -| `/switch ` | `/sw`, `/cd` | Переключиться на диалог | -| `/rename ` | | Переименовать текущий диалог | -| `/rm [id]` | `/delete` | Удалить (текущий или по id) | -| `/interrupt` | `/stop`, `/cancel` | Прервать текущий ход | -| `/history` | `/h`, `/hist` | Показывает историю текущего диалога | -| `/pwd` | | Путь к state-file | -| `/exit`, `/quit` | | Выйти | +# Переименовать +agentik-cli conv-rename --server http://localhost:8080/agentik "$ID" "мой чат" -## Переменные среды (пробрасываются серверу через `--server`) +# Отправить ход и стримить ответ +agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2" -См. [`../standalone/README.md`](../standalone/README.md). На стороне -клиента они **не** интерпретируются — это лишь настройки запуска -агента. CLI только знает, по какому URL стучаться. +# Показать последние N сообщений +agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10 -## Известное ограничение +# Прервать активный ход +agentik-cli interrupt --server http://localhost:8080/agentik "$ID" -SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на -default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный -terminal. Это upstream-особенность Ktor SSE. +# Удалить +agentik-cli conv-delete --server http://localhost:8080/agentik "$ID" +``` + +## Формат вывода `send` + +Каждое SSE-событие печатается отдельной строкой `event <Type> ...` — +пригодно для парсинга через `awk`/`jq`-обёртки: + +``` +event StartReasoning +event StartResponse TEXT +event AppendText \n\n +event AppendText Привет! +event End +``` + +Терминальные события (`End`, `Interrupted`, `Error`) тоже +печатаются; CLI выходит сразу после `End`. + +## Почему JVM-only (а не KMP) + +Аргумент-парсер: `kotlinx.cli` 0.3.6 (JetBrains, KMP) — единственный +зрелый вариант, который линкуется нативно под `linuxX64`/`linuxArm64` +без upstream-багов. Альтернативы: + +- **clikt-multiplatform 5.x** имеет upstream-баг: `clikt` и + `clikt-mordant` оба объявляют `selfAndAncestors` в commonMain, и + Kotlin/Native linker падает на дубликате символа + (issue [ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598), + workaround — `kotlin.native.cacheKind.linuxX64=none`, что замедляет + сборку на порядки). Поэтому clikt отвергнут в пользу kotlinx.cli. +- **picocli** — JVM-only. +- **kotlinx-args** — мёртвый репозиторий. + +Нативные бинари `:agentik-cli` отложены до стабилизации +arg-parser-ситуации. ## Тесты -``` +```bash ./gradlew :agentik-cli:jvmTest ``` -23 теста: парсер slash-команд, event-рендер, state-repository. - ## Версии `gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`. diff --git a/agentik-cli/build.gradle.kts b/agentik-cli/build.gradle.kts index 14b01c9..e39f252 100644 --- a/agentik-cli/build.gradle.kts +++ b/agentik-cli/build.gradle.kts @@ -1,7 +1,6 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -import org.gradle.api.artifacts.ConfigurationContainer plugins { alias(libs.plugins.kotlin.multiplatform) @@ -12,79 +11,39 @@ plugins { kotlin { jvmToolchain(21) - // Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9, - // но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть. - compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - } - - // "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto. - // commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only) - // и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual, - // расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь. + // Одноразовый CLI. Пока только JVM. Native-бинари добавим позже — + // kotlinx.cli 0.3.6 не имеет linuxArm64, а clikt имеет upstream-баг + // с duplicate symbol selfAndAncestors между clikt и clikt-mordant. jvm() - macosX64() - macosArm64() - iosX64() - iosArm64() - iosSimulatorArm64() - linuxX64() - linuxArm64() - mingwX64() sourceSets { commonMain.dependencies { implementation(project(":proto")) + implementation(project(":client")) + + // kotlinx.cli 0.3.6 — KMP subcommand-парсер от JetBrains. + // clikt 5.x имеет upstream-баг: `duplicate symbol selfAndAncestors` + // между `clikt` и `clikt-mordant` при линковке native. kotlinx.cli + // таких проблем нет. + implementation(libs.kotlinx.cli) implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.core) - implementation(libs.kotlinx.serialization.json) - } - jvmMain.dependencies { - // :client JVM-only (ktor-cio). Подключаем только в jvmMain. - implementation(project(":client")) - // JLine для readline с историей и completion. - implementation(libs.jline) - } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.core) - // runTest { } — suspend test runner для commonTest. - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") - } - jvmTest.dependencies { - // JUnit нужен в jvmTest — kotlin-test на JVM = JUnit4. - implementation("junit:junit:4.13.2") } + // :agentik-cli — JVM-only one-shot CLI. Нет jvmMain / nativeMain. + // Всё (включая platformEnv) лежит в commonMain. } @OptIn(ExperimentalKotlinGradlePluginApi::class) jvm { binaries { executable { - mainClass.set("pw.binom.agentik.cli.MainKt") + mainClass.set("pw.binom.agentik.cli.AgentikCliKt") } } } - - // Native executables для десктопных таргетов. iOS пропускаем — там - // не бывает CLI-сессий. entryPoint задаём явно — паттерн зеркалится на старом :agentik-tui. - // (комментарий в agentik-tui/build.gradle.kts объясняет зачем). - // Реально рабочий native-Main пока stub (Platform.native.kt — error()), - // но kexe всё равно собирается: линкер не вызывает main, пока не запустишь. - @OptIn(ExperimentalKotlinGradlePluginApi::class) - listOf(linuxX64(), linuxArm64(), macosX64(), macosArm64(), mingwX64()).forEach { - it.binaries.executable { - entryPoint = "pw.binom.agentik.cli.main" - } - } } -// --- Fatjar (uberjar) --- -// -// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`. -// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register. - +// Fatjar — аналог :standalone. val shadowJarTask = tasks.register<ShadowJar>("shadowJar") { archiveBaseName.set("agentik-cli") archiveClassifier.set("all") @@ -92,20 +51,13 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") { group = "build" from(tasks.named("jvmJar")) - val cc = try { - @Suppress("UNCHECKED_CAST") - configurations as org.gradle.api.artifacts.ConfigurationContainer - } catch (_: ClassCastException) { - @Suppress("UNCHECKED_CAST") - (project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer - } - from(cc.getByName("jvmRuntimeClasspath")) + from(project.configurations.getByName("jvmRuntimeClasspath")) mergeServiceFiles() duplicatesStrategy = DuplicatesStrategy.EXCLUDE manifest { - attributes["Main-Class"] = "pw.binom.agentik.cli.MainKt" + attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt" attributes["Implementation-Title"] = "agentik-cli" attributes["Implementation-Version"] = project.version.toString() } diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt index 5aa7298..956acee 100644 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt @@ -1,323 +1,87 @@ package pw.binom.agentik.cli -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -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.proto.Message -import kotlin.time.Instant +import kotlinx.cli.ArgParser +import kotlinx.cli.ArgType +import kotlinx.cli.ExperimentalCli +import kotlinx.cli.Subcommand +import kotlinx.cli.default +import pw.binom.agentik.cli.commands.ConvDeleteSubcommand +import pw.binom.agentik.cli.commands.ConvLsSubcommand +import pw.binom.agentik.cli.commands.ConvNewSubcommand +import pw.binom.agentik.cli.commands.ConvRenameSubcommand +import pw.binom.agentik.cli.commands.ConvShowSubcommand +import pw.binom.agentik.cli.commands.InfoSubcommand +import pw.binom.agentik.cli.commands.InterruptSubcommand +import pw.binom.agentik.cli.commands.MsgsSubcommand +import pw.binom.agentik.cli.commands.SendSubcommand /** - * Главный класс REPL. + * Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`. + * Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`. * - * Управляет: - * - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]); - * - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer]. - * - персистентностью сессии (восстановление последнего диалога при перезапуске CLI). - * - * Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения. - * `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий - * увидит [Event.Interrupted] и сам завершится. + * Используется в `runAgentikCli` и в каждом subcommand'е для своего + * `--server`/`--id` (иначе subcommand не видит значения родителя). */ -class AgentikCli internal constructor(private val config: CliConfig) { +internal fun defaultServerUrl(): String = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik" +internal fun defaultAgentId(): String = platformEnv("AGENTIK_AGENT_ID") ?: "cli" - private val agent: Agent = CliPlatform.openAgent(baseUrl = config.server, id = config.id) - private val terminal: CliTerminal = CliPlatform.openTerminal( - historyFile = if (config.historyEnabled) stateFilePath() else null, - prompt = "agentik> ", - ) - private val sessionRepo = SessionRepository( - filePath = if (config.historyEnabled) stateFilePath() else null, - io = CliPlatform.sessionIo(), +/** + * Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда. + * + * ``` + * agentik-cli <command> [args...] + * + * Commands: + * conv-ls список диалогов + * conv-new [--temp] создать диалог, печатает id + * conv-show <id> метаданные диалога + * conv-delete <id> удалить диалог + * conv-rename <id> <title> переименовать + * msgs <id> [--limit N] показать сообщения + * send <id> <text...> отправить ход, стримит response-события в stdout + * interrupt <id> прервать текущий ход + * info показать конфиг + * + * `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е. + * `agentik-cli conv-ls --server http://...`), не до — kotlinx.cli не + * шарит опции родителя в subcommand. + */ +@OptIn(ExperimentalCli::class) +fun runAgentikCli(args: Array<String>) { + val parser = ArgParser(programName = "agentik-cli") + + val subcommands: List<Subcommand> = listOf( + ConvLsSubcommand(), + ConvNewSubcommand(), + ConvShowSubcommand(), + ConvDeleteSubcommand(), + ConvRenameSubcommand(), + MsgsSubcommand(), + SendSubcommand(), + InterruptSubcommand(), + InfoSubcommand(), ) + subcommands.forEach { parser.subcommands(it) } - private var currentConv: Conversation? = null - private var currentTitle: String? = null - private var lastEventAt: Instant = Instant.DISTANT_PAST - - private val scope = CoroutineScope(Dispatchers.Default) - - suspend fun run() { - try { - // Восстановление сессии. - val saved = sessionRepo.load() - if (saved != null) { - val conv = runCatching { agent.getConversation(saved.conversationId) } - .getOrNull() - if (conv != null) { - currentConv = conv - currentTitle = conv.title - lastEventAt = saved.lastEventAt - terminal.printSystem( - "восстановлен диалог ${shorten(conv.id)}" + - " (${conv.title ?: "без названия"})", - ) - } else { - terminal.printSystem( - "прошлый диалог ${shorten(saved.conversationId)} больше не существует", - ) - } - } - - printBanner() - - // Главный цикл. - while (scope.isActive) { - terminal.print(prompt()) - val line = terminal.readLine() ?: break // EOF → выходим - val trimmed = line.trim() - if (trimmed.isEmpty()) continue - - if (trimmed.startsWith("/")) { - when (val r = parseSlash(trimmed.substring(1))) { - is ParseResult.Success -> { - if (handleCommand(r.command) == CommandResult.Exit) break - } - is ParseResult.Failure -> terminal.printSystem(r.message) - } - } else { - handleUserMessage(trimmed) - } - } - } finally { - terminal.printSystem("до свидания.") - currentConv?.close() - terminal.close() - sessionRepo.close() - scope.cancel() - } - } - - // ============================================================ banner / prompt - - private suspend fun printBanner() { - terminal.println() - terminal.println("agentik-cli — id=${config.id} — type /help") - terminal.println("server: ${config.server}") - when (val c = currentConv) { - null -> terminal.println("диалог: не выбран — начните с /new или /switch <id>") - else -> terminal.println("диалог: ${shorten(c.id)} (${c.title ?: "без названия"})") - } - terminal.println() - } - - private fun prompt(): String = "agentik${if (currentConv != null) "" else " (-)"}> " - - private suspend fun printHelp() { - terminal.println( - """ - |Slash-команды: - | /help эта справка - | /new [title] создать новый диалог - | /list, /ls список диалогов (новые сверху) - | /switch <id>, /sw переключиться на диалог по id - | /rename <title> переименовать текущий диалог - | /delete [<id>], /rm удалить диалог (по id или текущий) - | /history, /h последние сообщения текущего диалога - | /interrupt, /stop прервать текущий ход - | /pwd показать текущий диалог - | /exit, /quit выйти (Ctrl-D тоже) - | - |Любой ввод без ведущего `/` отправляется агенту в текущий диалог. - """.trimMargin(), - ) - } - - // ============================================================ command dispatch - - private suspend fun handleCommand(cmd: SlashCommand): CommandResult = when (cmd) { - SlashCommand.Help -> { printHelp(); CommandResult.Continue } - SlashCommand.Exit, SlashCommand.Quit -> CommandResult.Exit - is SlashCommand.New -> { handleNew(cmd.title); CommandResult.Continue } - SlashCommand.List -> { handleList(); CommandResult.Continue } - is SlashCommand.Switch -> { handleSwitch(cmd.id); CommandResult.Continue } - is SlashCommand.Rename -> { handleRename(cmd.title); CommandResult.Continue } - is SlashCommand.Delete -> { handleDelete(cmd.id); CommandResult.Continue } - SlashCommand.Interrupt -> { handleInterrupt(); CommandResult.Continue } - SlashCommand.History -> { handleHistory(); CommandResult.Continue } - SlashCommand.Pwd -> { handlePwd(); CommandResult.Continue } - } - - private suspend fun handleNew(title: String?) { - val conv = agent.createConversation(temp = false) - if (title != null) conv.rename(title) - currentConv = conv - currentTitle = title ?: conv.title - lastEventAt = Instant.DISTANT_PAST - terminal.printSystem("создан диалог ${shorten(conv.id)}" + if (title != null) " — «$title»" else "") - sessionRepo.save(conv.id, lastEventAt) - } - - private suspend fun handleList() { - terminal.println("диалоги (новые сверху):") - agent.getConversations(offset = 0).collect { conv -> - val marker = if (conv.id == currentConv?.id) "*" else " " - val title = conv.title ?: "(без названия)" - terminal.println(" $marker ${shorten(conv.id)} $title [${conv.updatedAt}]") - } - } - - private suspend fun handleSwitch(id: String) { - val conv = agent.getConversation(id) - if (conv == null) { - terminal.printSystem("диалог $id не найден") - return - } - currentConv?.close() - currentConv = conv - currentTitle = conv.title - lastEventAt = Instant.DISTANT_PAST - sessionRepo.save(conv.id, lastEventAt) - terminal.printSystem("переключились на ${shorten(conv.id)} (${conv.title ?: "без названия"})") - } - - private suspend fun handleRename(title: String) { - val c = currentConv ?: run { - terminal.printSystem("нет активного диалога — /new") - return - } - c.rename(title) - currentTitle = title - terminal.printSystem("заголовок: $title") - } - - private suspend fun handleDelete(id: String?) { - val target = id ?: currentConv?.id - if (target == null) { - terminal.printSystem("нет диалога для удаления") - return - } - val ok = agent.deleteConversation(target) - if (ok) { - terminal.printSystem("удалён ${shorten(target)}") - if (target == currentConv?.id) { - currentConv?.close() - currentConv = null - currentTitle = null - sessionRepo.clear() - } - } else { - terminal.printSystem("диалог ${shorten(target)} не найден") - } - } - - private suspend fun handleInterrupt() { - val c = currentConv ?: run { - terminal.printSystem("нет активного диалога") - return - } - c.interrupt() - terminal.printSystem("прерывание отправлено") - } - - private suspend fun handlePwd() { - val c = currentConv ?: run { - terminal.printSystem("диалог: не выбран") - return - } - terminal.printSystem("id: ${c.id}") - terminal.printSystem("title: ${c.title ?: "—"}") - terminal.printSystem("updatedAt: ${c.updatedAt}") - terminal.printSystem("temporal: ${c.isTemporal}") - } - - private suspend fun handleHistory() { - val c = currentConv ?: run { - terminal.printSystem("нет активного диалога") - return - } - terminal.println("история:") - c.getMessages(after = Instant.DISTANT_PAST).collect { msg -> renderHistoryMessage(msg) } - } - - private suspend fun renderHistoryMessage(msg: Message) { - val prefix = " [${msg.date}] " - when (msg) { - is Message.UserMessage -> - terminal.println(prefix + "user | " + msg.content.text()) - is Message.AssistantMessage -> - terminal.println(prefix + "agent | " + msg.content.text()) - is Message.ToolCall -> - terminal.println(prefix + "tool>${msg.toolName} | ${msg.toolArgs.take(160)}") - is Message.ToolResult -> - terminal.println(prefix + "tool< | " + (msg.result?.take(160) ?: "null")) - is Message.Error -> - terminal.println(prefix + "<error${msg.code?.let { "/$it" } ?: ""}> ${msg.message}") - } - } - - private fun List<Content>.text(): String = - joinToString(separator = "") { c -> - when (c) { - is Content.Text -> c.body - is Content.Image -> "[image:${c.mime}:${c.data.size}B]" - } - } - - // ============================================================ user-message - - private suspend fun handleUserMessage(text: String) { - val conv = currentConv ?: run { - terminal.printSystem("нет активного диалога — /new") - return - } - - terminal.println() // пустая строка для визуального отделения блока - - val renderer = EventRenderer(terminal) - val turnFinished = CompletableDeferred<Unit>() - - // Подписчик events: принимает события и обновляет lastEventAt, - // по терминальному событию закрывает Deferred. - val eventsJob = scope.launch { - try { - conv.events(after = lastEventAt).collect { ev -> - renderer.render(ev) - if (ev.date > lastEventAt) { - lastEventAt = ev.date - sessionRepo.save(conv.id, lastEventAt) - } - if (ev is Event.End || ev is Event.Interrupted || ev is Event.Error) { - if (!turnFinished.isCompleted) turnFinished.complete(Unit) - } - } - } catch (t: Throwable) { - if (!turnFinished.isCompleted) turnFinished.complete(Unit) - if (t !is kotlinx.coroutines.CancellationException) { - terminal.printSystem("[events stream error] ${t.message}") - } - } - } - - try { - conv.send(listOf(Content.Text(text))) - turnFinished.await() - } catch (t: Throwable) { - terminal.printSystem("[send error] ${t.message}") - } finally { - eventsJob.cancel() - renderer.close() - terminal.println() - } - } - - // ============================================================ utils - - private fun shorten(id: String): String = id.take(8) - - private fun stateFilePath(): String? { - val home = CliPlatform.homeDir() ?: return null - val dir = "$home/.agentik" - return "$dir/cli-state.json" - } + parser.parse(args) } -private enum class CommandResult { Continue, Exit } +/** + * Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`, + * чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит + * свойства родителя в subcommand). + */ +abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) { + val serverUrl: String by option( + ArgType.String, fullName = "server", shortName = "s", + description = "Base URL агента (env AGENTIK_SERVER)", + ).default(defaultServerUrl()) + val agentId: String by option( + ArgType.String, fullName = "id", shortName = "i", + description = "Идентификатор агента (env AGENTIK_AGENT_ID)", + ).default(defaultAgentId()) +} + +fun main(args: Array<String>) { + runAgentikCli(args) +} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/CliPlatform.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/CliPlatform.kt deleted file mode 100644 index 3f18453..0000000 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/CliPlatform.kt +++ /dev/null @@ -1,52 +0,0 @@ -package pw.binom.agentik.cli - -import pw.binom.agentik.proto.Agent - -/** - * Платформенные зависимости CLI. Все вещи, требующие JVM-stdlib или - * нативных API (терминал, env, файловое IO для state-файла, HTTP-клиент), - * предоставляются здесь как `expect/actual`. - * - * Текущий статус: jvmMain полностью реализован (JLine + `java.io` + `:client`), - * nativeMain — заглушки (подключение native ktor-движков и termios — отдельная задача). - */ -expect object CliPlatform { - fun openAgent(baseUrl: String, id: String): Agent - - fun openTerminal( - historyFile: String?, - prompt: String, - ): CliTerminal - - /** HOME/USERPROFILE для пути пути state-файла; null если недоступна. */ - fun homeDir(): String? - - /** Переменная среды (native API). Для jvmMain — `System.getenv`. */ - fun env(key: String): String? - - /** Файловое IO для session-state; nativeMain возвращает no-op. */ - fun sessionIo(): SessionIo -} - -/** - * Абстракция терминала, нужная для REPL. suspend-методы, чтобы не блокировать - * event-loop агентного цикла во время ожидания ввода. - */ -interface CliTerminal { - val prompt: String - - /** Следующая строка пользователя (без prompt). null = EOF (Ctrl-D/Ctrl-Z). */ - suspend fun readLine(): String? - - /** Печатает строку + перевод строки. */ - suspend fun println(text: String = "") - - /** Печатает строку без перевода (для streamed chunks). */ - suspend fun print(text: String) - - /** Подсветить prompt (символы-разделители сообщений, системные баннеры и т.п.). */ - suspend fun printSystem(text: String) - - /** Закрыть терминал: restore raw mode, flush history file, ... */ - fun close() -} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/EventRenderer.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/EventRenderer.kt deleted file mode 100644 index 84c2634..0000000 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/EventRenderer.kt +++ /dev/null @@ -1,82 +0,0 @@ -package pw.binom.agentik.cli - -import pw.binom.agentik.proto.Event - -/** - * Печатает [Event] в человеко-читаемом виде через [CliTerminal]. - * - * Дизайн: - * - [Event.StartReasoning] — просто системный маркер; текст мысли НЕ выводим - * отдельным форматом (см. proto: reasonig текст идёт через [Event.AppendText]). - * - [Event.StartResponse] с `responseType=TEXT` — начало печати ответа; закрытие - * происходит при [Event.End] или [Event.Interrupted]. - * - [Event.AppendText] — кусок текста, печатается БЕЗ перевода строки (чанки). - * - [Event.AppendImage] — выводим как `[image: <mime>, <bytes> bytes]` placeholder. - * Реальный рендеринг сделаем позже через iTerm/Kitty протоколы. - * - [Event.End] / [Event.Interrupted] — закрывают текущий блок. - * - [Event.Error] — отдельный системный блок `[error: …]`. - */ -class EventRenderer(private val terminal: CliTerminal) { - - /** Трекает открыт ли сейчас «блок ответа» (после [Event.StartResponse], до [Event.End]). */ - private var responseOpen = false - - suspend fun render(event: Event) { - when (event) { - is Event.StartReasoning -> { - terminal.printSystem("…thinking…") - if (responseOpen) { - terminal.println() - responseOpen = false - } - } - - is Event.StartResponse -> { - if (responseOpen) terminal.println() - responseOpen = true - // Без префикса — текст будет стримиться дальше через AppendText. - } - - is Event.AppendText -> { - terminal.print(event.body) - } - - is Event.AppendImage -> { - terminal.print("[image:${event.mime}:${event.body.size} bytes]") - } - - is Event.Interrupted -> { - if (responseOpen) { - terminal.println() - terminal.printSystem("[interrupted]") - responseOpen = false - } else { - terminal.printSystem("[interrupted]") - } - } - - is Event.End -> { - if (responseOpen) { - terminal.println() - responseOpen = false - } - } - - is Event.Error -> { - terminal.println() - terminal.printSystem("[error${event.code?.let { "/$it" } ?: ""}] ${event.message}") - if (responseOpen) responseOpen = false - } - - else -> { - // ToolCall/ToolResult — это «структура» диалога, в текстовом стриме - // не показываем; в веб-UI будет по-другому. - terminal.printSystem("[event:${event::class.simpleName}]") - } - } - } - - fun close() { - responseOpen = false - } -} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/Main.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/Main.kt deleted file mode 100644 index f889cd4..0000000 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/Main.kt +++ /dev/null @@ -1,105 +0,0 @@ -package pw.binom.agentik.cli - -import kotlinx.coroutines.runBlocking - -/** - * Точка входа CLI. Поддерживает аргументы командной строки: - * - * ``` - * agentik-cli [--server URL] [--id ID] [--no-history] [--help] - * - * --server URL базовый URL сервера agentik (default $AGENTIK_SERVER или - * http://localhost:8080/agentik) - * --id ID идентификатор этого клиента (default "cli:$USER") - * --no-history не сохранять состояние в ~/.agentik/cli-state.json - * --help, -h распечатать usage и выйти - * ``` - * - * Без аргументов — стартует REPL. - */ -fun main(args: Array<String>) = runBlocking { - val cfg = parseCliArgs(args) - if (cfg == null) { - printUsage() - return@runBlocking - } - AgentikCli(cfg).run() -} - -/** - * Конфигурация CLI, вычисленная из аргументов + переменных среды. - * Доступна из других файлов commonMain (видна как `internal` внутри модуля). - */ -internal data class CliConfig( - val server: String, - val id: String, - val historyEnabled: Boolean, -) - -private fun parseCliArgs(args: Array<String>): CliConfig? { - var server: String? = null - var id: String? = null - var historyEnabled = true - - var i = 0 - while (i < args.size) { - when (val a = args[i]) { - "--help", "-h", "help" -> return null - "--server", "-s" -> { - require(i + 1 < args.size) { "$a требует URL" } - server = args[i + 1]; i += 2 - } - "--id" -> { - require(i + 1 < args.size) { "$a требует значение" } - id = args[i + 1]; i += 2 - } - "--no-history" -> { historyEnabled = false; i++ } - "--" -> i++ // разделитель; остальное игнорируем - else -> error("неизвестный аргумент: $a (введите --help)") - } - } - - val resolvedServer = server - ?: CliPlatform.env("AGENTIK_SERVER") - ?: "http://localhost:8080/agentik" - val resolvedId = id ?: "cli:${CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"}" - - return CliConfig( - server = resolvedServer, - id = resolvedId, - historyEnabled = historyEnabled, - ) -} - -private fun printUsage() { - val defaultServer = CliPlatform.env("AGENTIK_SERVER") ?: "http://localhost:8080/agentik" - val defaultUser = CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon" - println(""" - agentik-cli — REPL поверх протокола agentik - - Использование: - agentik-cli [--server URL] [--id ID] [--no-history] - - Аргументы: - --server, -s URL базовый URL (default: $defaultServer) - --id ID идентификатор клиента (default: cli:${defaultUser}) - --no-history не сохранять состояние в ~/.agentik/cli-state.json - --help, -h эта справка - - Переменные среды: - AGENTIK_SERVER базовый URL агента (используется если --server не задан) - HOME для пути ~/.agentik/cli-state.json - - В REPL: - /help список slash-команд - /new [title] создать диалог (title опционально) - /list, /ls список диалогов - /switch <id>, /sw <id> переключиться на диалог - /rename <title> переименовать текущий диалог - /delete [<id>], /rm удалить (по id или текущий) - /history, /h последние сообщения текущего диалога - /interrupt, /stop прервать текущий ход - /pwd показать текущий диалог - /exit, /quit выйти (Ctrl-D тоже работает) - """.trimIndent()) -} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/PlatformEnv.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/PlatformEnv.kt new file mode 100644 index 0000000..57ed8e0 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/PlatformEnv.kt @@ -0,0 +1,3 @@ +package pw.binom.agentik.cli + +internal fun platformEnv(key: String): String? = System.getenv(key) diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SessionRepository.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SessionRepository.kt deleted file mode 100644 index 53a9374..0000000 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SessionRepository.kt +++ /dev/null @@ -1,81 +0,0 @@ -package pw.binom.agentik.cli - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlin.time.Instant - -/** - * Состояние CLI между запусками: последний выбранный диалог и момент последнего - * увиденного [Event.date] в его потоке (для корректного `events(after)` после рестарта). - * - * Доступ к диску инкапсулирован в платформенный [CliPlatform] — commonMain ничего - * не знает про `java.io.File`/`NSFileManager`, чтобы KMP-сборка собиралась - * под все цели. Файл: `$HOME/.agentik/cli-state.json`. - */ -internal class SessionRepository internal constructor( - private val filePath: String?, - private val io: SessionIo, -) { - - @Serializable - private data class State( - val conversationId: String, - val lastEventAt: String, - ) - - private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } - - /** Открывается ленивым чтением. [save] ещё не было — файл может отсутствовать. */ - private var cached: State? = null - - fun load(): SavedSession? { - val path = filePath ?: return null - val raw = io.readAll(path) ?: return null - return runCatching { - val state = json.decodeFromString(State.serializer(), raw) - cached = state - SavedSession( - conversationId = state.conversationId, - lastEventAt = Instant.parse(state.lastEventAt), - ) - }.getOrNull() - } - - fun save(conversationId: String, lastEventAt: Instant) { - val path = filePath ?: return - val state = State( - conversationId = conversationId, - lastEventAt = lastEventAt.toString(), - ) - cached = state - val body = json.encodeToString(State.serializer(), state) - io.writeAtomic(path, body) - } - - fun clear() { - val path = filePath ?: return - io.delete(path) - cached = null - } - - fun close() { - // для совместимости с будущим in-memory state; пока no-op - } -} - -internal data class SavedSession( - val conversationId: String, - val lastEventAt: Instant, -) - -/** - * Минимальный платформо-зависимый IO-интерфейс для одного файла. Реализации - * в jvmMain (`java.io.File` + atomic `tmp → rename`) и в nativeMain (пока no-op-stub). - * - * public, потому что его возвращает public [CliPlatform.sessionIo]. - */ -interface SessionIo { - fun readAll(path: String): String? - fun writeAtomic(path: String, body: String) - fun delete(path: String) -} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SlashCommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SlashCommand.kt deleted file mode 100644 index 3987099..0000000 --- a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SlashCommand.kt +++ /dev/null @@ -1,90 +0,0 @@ -package pw.binom.agentik.cli - -/** - * Slash-команды REPL'а. Первая буква `/` не хранится — парсер уже её отрезал. - * - * Свободный ввод (без `/` в начале) — это сообщение пользователя агенту в - * текущий диалог и НЕ разбирается в [parse]. - */ -sealed interface SlashCommand { - data object Help : SlashCommand - data object Exit : SlashCommand - data object Quit : SlashCommand // синоним Exit - - /** Создать новый диалог; опционально — заголовок. */ - data class New(val title: String?) : SlashCommand - - /** Список диалогов (cold flow — печатаем по мере прихода страниц). */ - data object List : SlashCommand - - /** Подключиться к существующему диалогу по id. */ - data class Switch(val id: String) : SlashCommand - - /** Переименовать текущий диалог. */ - data class Rename(val title: String) : SlashCommand - - /** Удалить диалог (по id или текущий). */ - data class Delete(val id: String?) : SlashCommand - - /** Прервать текущий ход. no-op если хода нет. */ - data object Interrupt : SlashCommand - - /** Показать последние сообщения текущего диалога (cold flow). */ - data object History : SlashCommand - - /** Показать информацию о текущем диалоге. */ - data object Pwd : SlashCommand -} - -/** - * Парсит строку (без ведущего `/`) в [SlashCommand] либо возвращает [Result.Failure] - * с сообщением об ошибке. - * - * Команды нечувствительны к регистру (команда `/LIST` == `/list`). - */ -fun parseSlash(input: String): ParseResult { - val s = input.trim() - if (s.isEmpty()) return ParseResult.Failure("пустая команда (введите /help)") - - // Разбиваем на команду и её аргументы. Поддерживаем склейку: /new foo bar → new "foo bar" - val firstSpace = s.indexOfAny(charArrayOf(' ', '\t')) - val cmd = if (firstSpace < 0) s else s.substring(0, firstSpace) - val rest = if (firstSpace < 0) "" else s.substring(firstSpace + 1).trim() - val args = if (rest.isEmpty()) emptyList() else rest.split(' ').filter { it.isNotEmpty() } - - val command: SlashCommand? = when (cmd.lowercase()) { - "help", "?" -> SlashCommand.Help - "exit" -> SlashCommand.Exit - "quit", "q" -> SlashCommand.Quit - "new" -> SlashCommand.New(rest.takeIf { it.isNotEmpty() }) - "list", "ls" -> SlashCommand.List - "switch", "sw", "cd" -> args.firstOrNull()?.let { SlashCommand.Switch(it) } - "rename", "mv", "title" -> rest.takeIf { it.isNotEmpty() }?.let { SlashCommand.Rename(it) } - "delete", "rm" -> SlashCommand.Delete(args.firstOrNull()) - "interrupt", "stop", "cancel" -> SlashCommand.Interrupt - "history", "hist", "h" -> SlashCommand.History - "pwd", "where" -> SlashCommand.Pwd - else -> null - } - if (command != null) return ParseResult.Success(command) - - // Не нашли команду: либо неизвестная, либо не хватает аргумента. - val cmdLower = cmd.lowercase() - return when (cmdLower) { - "switch", "sw", "cd" -> ParseResult.Failure("укажите id диалога: /switch <id>") - "rename", "mv", "title" -> ParseResult.Failure("укажите заголовок: /rename <title>") - else -> ParseResult.Failure("неизвестная команда: /$cmd (введите /help)") - } -} - -sealed interface ParseResult { - data class Success(val command: SlashCommand) : ParseResult - data class Failure(val message: String) : ParseResult -} - -/** Удобный helper для тестов и общего кода. */ -fun parseSlashOrNull(input: String): SlashCommand? = - when (val r = parseSlash(input)) { - is ParseResult.Success -> r.command - is ParseResult.Failure -> null - } 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 new file mode 100644 index 0000000..07d8617 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvDeleteSubcommand.kt @@ -0,0 +1,15 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.client.AgentikAgent + +class ConvDeleteSubcommand : AgentikSubcommand("conv-delete", "Удалить диалог") { + val id by argument(ArgType.String, description = "ID диалога") + + override fun execute() = kotlinx.coroutines.runBlocking { + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + 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 new file mode 100644 index 0000000..f4cd7a9 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvLsSubcommand.kt @@ -0,0 +1,32 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import kotlinx.cli.default +import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.client.AgentikAgent +import pw.binom.agentik.proto.Agent + +class ConvLsSubcommand : AgentikSubcommand("conv-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 convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE)) + if (convs.isEmpty()) { + println("(no conversations)") + return@runBlocking + } + println("ID UPDATED-AT TITLE FLAGS") + convs.forEach { c -> + val flags = buildString { + if (c.isTemporal) append('T') + if (c.isSupportImageInput) append('I') + if (c.isSupportImageOutput) append('O') + if (isEmpty()) append('-') + } + val title = c.title ?: "(untitled)" + println("${c.id.padEnd(38)} ${c.updatedAt.toString().padEnd(22)} ${title.take(30).padEnd(31)} $flags") + } + println("--- ${convs.size} conversation(s)") + } +} 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 new file mode 100644 index 0000000..ab028a1 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvNewSubcommand.kt @@ -0,0 +1,16 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import kotlinx.cli.default +import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.client.AgentikAgent + +class ConvNewSubcommand : AgentikSubcommand("conv-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 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 new file mode 100644 index 0000000..ada3c38 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvRenameSubcommand.kt @@ -0,0 +1,24 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.client.AgentikAgent + +class ConvRenameSubcommand : AgentikSubcommand("conv-rename", "Переименовать диалог") { + val id by argument(ArgType.String, description = "ID диалога") + val title by argument(ArgType.String, description = "Новое название") + + override fun execute() = kotlinx.coroutines.runBlocking { + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val conv = agent.getConversation(id) ?: run { + println("conversation not found: $id") + return@runBlocking + } + try { + conv.rename(title) + } finally { + conv.close() + } + println("renamed: $id -> $title") + } +} 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 new file mode 100644 index 0000000..820b3a2 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/ConvShowSubcommand.kt @@ -0,0 +1,27 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.client.AgentikAgent + +class ConvShowSubcommand : AgentikSubcommand("conv-show", "Метаданные диалога") { + val id by argument(ArgType.String, description = "ID диалога") + + override fun execute() = kotlinx.coroutines.runBlocking { + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val conv = agent.getConversation(id) ?: run { + println("conversation not found: $id") + return@runBlocking + } + try { + println("id: ${conv.id}") + println("title: ${conv.title ?: "(untitled)"}") + println("updatedAt: ${conv.updatedAt}") + println("isTemporal: ${conv.isTemporal}") + println("isSupportImageInput: ${conv.isSupportImageInput}") + println("isSupportImageOutput: ${conv.isSupportImageOutput}") + } finally { + conv.close() + } + } +} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InfoSubcommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InfoSubcommand.kt new file mode 100644 index 0000000..bdb586b --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InfoSubcommand.kt @@ -0,0 +1,10 @@ +package pw.binom.agentik.cli.commands + +import pw.binom.agentik.cli.AgentikSubcommand + +class InfoSubcommand : AgentikSubcommand("info", "Показать server URL и agent id") { + override fun execute() { + println("server: $serverUrl") + println("id: $agentId") + } +} 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 new file mode 100644 index 0000000..2424c58 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/InterruptSubcommand.kt @@ -0,0 +1,23 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import pw.binom.agentik.cli.AgentikSubcommand +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 conv = agent.getConversation(id) ?: run { + println("conversation not found: $id") + return@runBlocking + } + try { + conv.interrupt() + println("interrupted: $id") + } finally { + conv.close() + } + } +} 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 new file mode 100644 index 0000000..8ffbed2 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/MsgsSubcommand.kt @@ -0,0 +1,54 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import kotlinx.cli.default +import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.client.AgentikAgent +import pw.binom.agentik.proto.Content +import pw.binom.agentik.proto.Message +import kotlin.time.Instant + +class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообщения диалога") { + val id by argument(ArgType.String, description = "ID диалога") + 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 conv = agent.getConversation(id) ?: run { + println("conversation not found: $id") + return@runBlocking + } + try { + val msgs = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = limit) + .sortedBy { it.date } + msgs.forEach { m -> println(formatMessage(m)) } + println("--- ${msgs.size} message(s)") + } finally { + conv.close() + } + } + + private fun formatMessage(m: Message): String = + "[${m.date}] ${m.role().padEnd(11)} ${m.bodyOneLine()}" + + private fun Message.role(): String = when (this) { + is Message.UserMessage -> "[user]" + is Message.AssistantMessage -> "[assistant]" + is Message.ToolCall -> "[tool_call]" + is Message.ToolResult -> "[tool_result]" + is Message.Error -> "[error]" + } + + private fun Message.bodyOneLine(): String = when (this) { + is Message.UserMessage -> content.joinToString(" ") { c -> c.toOneLine() } + is Message.AssistantMessage -> content.joinToString(" ") { c -> c.toOneLine() } + is Message.ToolCall -> "tool=$toolName args=$toolArgs" + is Message.ToolResult -> "id=$id result=${result ?: "<null>"}" + is Message.Error -> "code=${code ?: "?"} message=$message" + } + + private fun Content.toOneLine(): String = when (this) { + is Content.Text -> body.replace('\n', ' ').take(200) + is Content.Image -> "<image ${data.size}B $mime>" + } +} 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 new file mode 100644 index 0000000..301034e --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/commands/SendSubcommand.kt @@ -0,0 +1,63 @@ +package pw.binom.agentik.cli.commands + +import kotlinx.cli.ArgType +import kotlinx.cli.vararg +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch +import pw.binom.agentik.cli.AgentikSubcommand +import pw.binom.agentik.client.AgentikAgent +import pw.binom.agentik.proto.Content +import pw.binom.agentik.proto.Event +import kotlin.time.Instant + +class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") { + val id by argument(ArgType.String, description = "ID диалога") + val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg() + + override fun execute() = kotlinx.coroutines.runBlocking { + val agent = AgentikAgent(id = agentId, baseUrl = serverUrl) + val conv = agent.getConversation(id) ?: run { + println("conversation not found: $id") + return@runBlocking + } + try { + // Подписываемся на поток событий ДО send: события, отправленные + // до подписки, не реплеятся (shared-flow без replay). + val eventsJob = launch { + conv.events(Instant.DISTANT_PAST) + // onEach печатает и терминальный event, takeWhile лишь + // завершает сбор после него. + .onEach { ev -> emit(ev) } + .takeWhile { ev -> !isTerminal(ev) } + .collect { } + } + // Даём SSE-подписке установиться, затем шлём ход. + delay(200) + conv.send(listOf(Content.Text(text.joinToString(" ")))) + eventsJob.join() + } finally { + conv.close() + } + } + + private fun isTerminal(ev: Event): Boolean = + ev is Event.End || ev is Event.Interrupted || ev is Event.Error + + private fun emit(ev: Event) { + when (ev) { + is Event.StartReasoning -> println("event StartReasoning") + is Event.StartResponse -> println("event StartResponse ${ev.responseType}") + 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.End -> println("event End") + is Event.Interrupted -> println("event Interrupted") + is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}") + } + } + + private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r") +} diff --git a/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/EventRendererTest.kt b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/EventRendererTest.kt deleted file mode 100644 index bb4a9e4..0000000 --- a/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/EventRendererTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package pw.binom.agentik.cli - -import kotlinx.coroutines.test.runTest -import pw.binom.agentik.proto.Event -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * Подменяем [CliTerminal] простой in-memory реализацией и проверяем, - * что события рендерятся в правильном формате. - */ -class EventRendererTest { - - private class FakeTerminal : CliTerminal { - override val prompt: String = ">" - val out = StringBuilder() - override suspend fun readLine(): String? = null - override suspend fun println(text: String) { out.appendLine(text) } - override suspend fun print(text: String) { out.append(text) } - override suspend fun printSystem(text: String) { out.appendLine("· $text") } - override fun close() {} - fun text() = out.toString() - } - - @Test - fun `simple response stream`() = runTest { - val t = FakeTerminal() - val r = EventRenderer(t) - r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT)) - r.render(Event.AppendText(Instant.DISTANT_PAST, "Привет")) - r.render(Event.AppendText(Instant.DISTANT_PAST, ", мир!")) - r.render(Event.End(Instant.DISTANT_PAST)) - - // StartResponse открывает блок, AppendText без \n, End закрывает \n - val text = t.text() - assertTrue(text.contains("Привет, мир!"), "got: $text") - // после End должен быть перевод строки - assertTrue(text.endsWith("\n")) - } - - @Test - fun `interrupted closes block`() = runTest { - val t = FakeTerminal() - val r = EventRenderer(t) - r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT)) - r.render(Event.AppendText(Instant.DISTANT_PAST, "Частично")) - r.render(Event.Interrupted(Instant.DISTANT_PAST)) - val text = t.text() - assertTrue(text.contains("Частично")) - assertTrue(text.contains("· [interrupted]")) - } - - @Test - fun `error before response`() = runTest { - val t = FakeTerminal() - val r = EventRenderer(t) - r.render(Event.Error(Instant.DISTANT_PAST, message = "что-то сломалось", code = "500")) - val text = t.text() - assertTrue(text.contains("· [error/500] что-то сломалось")) - } - - @Test - fun `start_reasoning is printed as system line`() = runTest { - val t = FakeTerminal() - val r = EventRenderer(t) - r.render(Event.StartReasoning(Instant.DISTANT_PAST)) - assertTrue(t.text().contains("· …thinking…")) - } - - @Test - fun `image append renders placeholder`() = runTest { - val t = FakeTerminal() - val r = EventRenderer(t) - r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.IMAGE)) - r.render(Event.AppendImage(Instant.DISTANT_PAST, body = ByteArray(64), mime = "image/png")) - r.render(Event.End(Instant.DISTANT_PAST)) - assertTrue(t.text().contains("[image:image/png:64 bytes]")) - } -} diff --git a/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SlashCommandTest.kt b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SlashCommandTest.kt deleted file mode 100644 index e4381f9..0000000 --- a/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SlashCommandTest.kt +++ /dev/null @@ -1,101 +0,0 @@ -package pw.binom.agentik.cli - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertTrue - -class SlashCommandTest { - - @Test - fun `help is parsed`() { - assertIs<SlashCommand.Help>(parseSlashOrNull("help")) - assertIs<SlashCommand.Help>(parseSlashOrNull("?")) - assertIs<SlashCommand.Help>(parseSlashOrNull("HELP")) - } - - @Test - fun `exit and quit alias`() { - assertIs<SlashCommand.Exit>(parseSlashOrNull("exit")) - assertIs<SlashCommand.Quit>(parseSlashOrNull("q")) - assertIs<SlashCommand.Quit>(parseSlashOrNull("Quit")) - } - - @Test - fun `new without title`() { - assertIs<SlashCommand.New>(parseSlashOrNull("new")).let { - assertEquals(null, it.title) - } - } - - @Test - fun `new with multi-word title`() { - val cmd = parseSlashOrNull("new my cool chat") - assertIs<SlashCommand.New>(cmd) - assertEquals("my cool chat", cmd.title) - } - - @Test - fun `switch requires id`() { - val r = parseSlash("sw") - assertIs<ParseResult.Failure>(r) - } - - @Test - fun `switch with id`() { - val cmd = parseSlashOrNull("switch abc123") - assertIs<SlashCommand.Switch>(cmd) - assertEquals("abc123", cmd.id) - } - - @Test - fun `rename requires title`() { - val r = parseSlash("rename") - assertIs<ParseResult.Failure>(r) - // А "rename " (с пробелом, но без слов после) — это уже успех с пустым title? - // У нас: rest = "" → takeIf { it.isNotEmpty() } → null → Failure. ОК. - } - - @Test - fun `rename with title`() { - val cmd = parseSlashOrNull("rename my new title ") - assertIs<SlashCommand.Rename>(cmd) - assertEquals("my new title", cmd.title) // trim() делает своё - } - - @Test - fun `delete may have id or not`() { - assertIs<SlashCommand.Delete>(parseSlashOrNull("rm")).let { - assertEquals(null, it.id) - } - assertIs<SlashCommand.Delete>(parseSlashOrNull("delete abc")).let { - assertEquals("abc", it.id) - } - } - - @Test - fun `unknown command fails`() { - val r = parseSlash("foobar") - assertIs<ParseResult.Failure>(r) - } - - @Test - fun `empty command fails`() { - val r = parseSlash("") - assertIs<ParseResult.Failure>(r) - } - - @Test - fun `command is case insensitive`() { - assertIs<SlashCommand.List>(parseSlashOrNull("LIST")) - assertIs<SlashCommand.Interrupt>(parseSlashOrNull("STOP")) - assertIs<SlashCommand.Pwd>(parseSlashOrNull("PWD")) - } - - @Test - fun `interrupt synonyms`() { - assertIs<SlashCommand.Interrupt>(parseSlashOrNull("interrupt")) - assertIs<SlashCommand.Interrupt>(parseSlashOrNull("stop")) - assertIs<SlashCommand.Interrupt>(parseSlashOrNull("cancel")) - } -} diff --git a/agentik-cli/src/jvmMain/kotlin/pw/binom/agentik/cli/Platform.jvm.kt b/agentik-cli/src/jvmMain/kotlin/pw/binom/agentik/cli/Platform.jvm.kt deleted file mode 100644 index 5064d10..0000000 --- a/agentik-cli/src/jvmMain/kotlin/pw/binom/agentik/cli/Platform.jvm.kt +++ /dev/null @@ -1,151 +0,0 @@ -package pw.binom.agentik.cli - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jline.reader.EndOfFileException -import org.jline.reader.LineReader -import org.jline.reader.LineReaderBuilder -import org.jline.reader.UserInterruptException -import org.jline.terminal.TerminalBuilder -import pw.binom.agentik.client.AgentikAgent -import pw.binom.agentik.proto.Agent -import java.io.File -import java.nio.file.Files -import java.nio.file.StandardCopyOption - -actual object CliPlatform { - actual fun openAgent(baseUrl: String, id: String): Agent = - AgentikAgent(id = id, baseUrl = baseUrl) - - actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal = - JLineTerminal(historyFile = historyFile, prompt = prompt) - - actual fun homeDir(): String? = - System.getenv("HOME") ?: System.getenv("USERPROFILE") - - actual fun env(key: String): String? = System.getenv(key) - - actual fun sessionIo(): SessionIo = JvmSessionIo -} - -/** - * Реализация [SessionIo] поверх `java.io.File` + atomic `tmp → rename`. - * tmp-файл пишется в той же директории, что и целевой, чтобы rename - * был атомарным в рамках одного раздела (POSIX rename(2) и Windows - * MoveFileEx — атомарны внутри одного тома). - */ -private object JvmSessionIo : SessionIo { - override fun readAll(path: String): String? { - val f = File(path) - if (!f.exists()) return null - return runCatching { f.readText() }.getOrNull() - } - - override fun writeAtomic(path: String, body: String) { - val target = File(path) - target.parentFile?.mkdirs() - val tmp = File(path + ".tmp") - tmp.writeText(body) - if (!tmp.renameTo(target)) { - // fallback: Windows-специфика — renameTo может не перезаписать существующий. - runCatching { Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } - .getOrElse { target.writeText(tmp.readText()); tmp.delete() } - } - } override fun delete(path: String) { - runCatching { File(path).delete() } - } -} - -/** - * Реализация [CliTerminal] поверх JLine ([LineReader]). - * - * JLine-3 API: - * - [TerminalBuilder.builder().system(true).build()] — открыть системный TTY. - * - [LineReader] поверх Terminal — readline-редактор (стрелки, history, Ctrl-A/E). - * - [LineReader.readLine(prompt)] — suspend-free, блокирующий IO; мы оборачиваем - * в [withContext] [Dispatchers.IO], чтобы не держать event-loop. - * - [DefaultHistory] (org.jline.reader.history.DefaultHistory) + история из файла. - */ -private class JLineTerminal( - historyFile: String?, - override val prompt: String, -) : CliTerminal { - - private val terminal = TerminalBuilder.builder() - .system(true) - .jna(true) - .build() - - private val historyImpl: org.jline.reader.History? = run { - if (historyFile == null) null else try { - val history = org.jline.reader.impl.history.DefaultHistory() - val histFile = File(historyFile) - histFile.parentFile?.mkdirs() - history.load() - if (histFile.exists()) { - history.append(histFile.toPath(), true) - } - history - } catch (t: Throwable) { - null - } - } - - private val reader: LineReader = LineReaderBuilder.builder() - .terminal(terminal) - .apply { if (historyImpl != null) history(historyImpl) } - .build() - - private val historyFilePath: java.nio.file.Path? = - historyFile?.let { File(it).toPath() } - - override suspend fun readLine(): String? = withContext(Dispatchers.IO) { - try { - val line = reader.readLine(prompt) - // Сохраняем history при каждой строке — дешево, и при Ctrl-D / Ctrl-C - // ничего не теряется. - flushHistory() - line - } catch (_: UserInterruptException) { - // Ctrl-C: трактуем как «всё, выходим», как и EOF. - flushHistory() - null - } catch (_: EndOfFileException) { - // Ctrl-D на пустой строке. - flushHistory() - null - } - } - - override suspend fun println(text: String): Unit = withContext(Dispatchers.IO) { - terminal.writer().println(text) - terminal.writer().flush() - } - - override suspend fun print(text: String): Unit = withContext(Dispatchers.IO) { - terminal.writer().print(text) - terminal.writer().flush() - } - - override suspend fun printSystem(text: String): Unit = withContext(Dispatchers.IO) { - terminal.writer().println("· $text") - terminal.writer().flush() - } - - private fun flushHistory() { - val hf = historyFilePath ?: return - val h = historyImpl ?: return - runCatching { - h.save() - if (!h.isEmpty) { - // читаем из .tmp и дописываем - h.append(hf, true) - } - } - } - - override fun close() { - runCatching { flushHistory() } - runCatching { terminal.close() } - } -} diff --git a/agentik-cli/src/jvmTest/kotlin/pw/binom/agentik/cli/SessionRepositoryTest.kt b/agentik-cli/src/jvmTest/kotlin/pw/binom/agentik/cli/SessionRepositoryTest.kt deleted file mode 100644 index a64285d..0000000 --- a/agentik-cli/src/jvmTest/kotlin/pw/binom/agentik/cli/SessionRepositoryTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package pw.binom.agentik.cli - -import org.junit.After -import org.junit.Before -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * Интеграционный тест на реальном временном файле. Только JVM: использует - * [java.io.File] для IO-интерфейса. На native-таргетах тест не собирается — - * TODO: переписать на kotlinx-io Files и перенести в commonTest. - */ -class SessionRepositoryTest { - - private lateinit var tmp: File - - @Before - fun setUp() { - tmp = File.createTempFile("agentik-cli-state", ".json") - tmp.delete() - } - - @After - fun tearDown() { - if (tmp.exists()) tmp.delete() - File(tmp.path + ".tmp").delete() - } - - @Test - fun `load returns null when file missing`() { - val repo = SessionRepository(tmp.path, JvmIo) - assertNull(repo.load()) - } - - @Test - fun `save then load roundtrip`() { - val repo = SessionRepository(tmp.path, JvmIo) - val savedAt = Instant.parse("2026-09-16T10:00:00Z") - repo.save(conversationId = "abcd-1234", lastEventAt = savedAt) - repo.close() - - val repo2 = SessionRepository(tmp.path, JvmIo) - val restored = repo2.load() - assertNotNull(restored) - assertEquals("abcd-1234", restored.conversationId) - assertEquals(savedAt, restored.lastEventAt) - } - - @Test - fun `save overwrites previous state`() { - val repo = SessionRepository(tmp.path, JvmIo) - repo.save("conv-1", Instant.parse("2026-09-16T10:00:00Z")) - repo.save("conv-2", Instant.parse("2026-09-16T11:00:00Z")) - repo.close() - - val restored = SessionRepository(tmp.path, JvmIo).load() - assertNotNull(restored) - assertEquals("conv-2", restored.conversationId) - } - - @Test - fun `null filepath means no-op`() { - val repo = SessionRepository(null, JvmIo) - repo.save("conv-X", Instant.parse("2026-09-16T10:00:00Z")) - // Не должно ни читать, ни писать. - assertNull(repo.load()) - } - - @Test - fun `clear deletes file`() { - val repo = SessionRepository(tmp.path, JvmIo) - repo.save("conv-Z", Instant.parse("2026-09-16T10:00:00Z")) - repo.close() - assertTrue(tmp.exists()) - - val repo2 = SessionRepository(tmp.path, JvmIo) - repo2.clear() - assertTrue(!tmp.exists()) - } - - @Test - fun `corrupt json is ignored (does not throw)`() { - File(tmp.path).writeText("this is not json") - val repo = SessionRepository(tmp.path, JvmIo) - assertNull(repo.load()) - } -} - -// JVM-only helper: реализация [SessionIo] поверх `java.io.File` для теста. -// В продакшен-коде на jvmMain ровно такая же логика. -private object JvmIo : SessionIo { - override fun readAll(path: String): String? { - val f = File(path); if (!f.exists()) return null - return runCatching { f.readText() }.getOrNull() - } - override fun writeAtomic(path: String, body: String) { - val target = File(path); target.parentFile?.mkdirs() - val tmp = File(path + ".tmp") - tmp.writeText(body) - if (!tmp.renameTo(target)) target.writeText(tmp.readText()).also { tmp.delete() } - } - override fun delete(path: String) { File(path).delete() } -} diff --git a/agentik-cli/src/nativeMain/kotlin/pw/binom/agentik/cli/Platform.native.kt b/agentik-cli/src/nativeMain/kotlin/pw/binom/agentik/cli/Platform.native.kt deleted file mode 100644 index c16993c..0000000 --- a/agentik-cli/src/nativeMain/kotlin/pw/binom/agentik/cli/Platform.native.kt +++ /dev/null @@ -1,36 +0,0 @@ -package pw.binom.agentik.cli - -import pw.binom.agentik.proto.Agent - -/** - * Платформо-зависимая реализация для native-целей. - * - * Текущий статус: stub. native HTTP требует подключения ktor-client-core + - * платформенных engine'ов (ktor-client-darwin для Apple, ktor-client-curl для - * linux/mingw, ktor-client-okhttp для Android в перспективе) и переиспользования - * уже существующего `:client` SSE-парсера. Native readline требует termios - * через `kotlinx.cinterop` — добавим, когда дойдут руки. - * - * Пока запустить агента из native-бинаря CLI нельзя, но проект компилируется - * под все 8 KMP-целей — структурная готовность соблюдена. - */ -actual object CliPlatform { - actual fun openAgent(baseUrl: String, id: String): Agent = - error("agentik-cli native target is not implemented yet (baseUrl=$baseUrl)") - - actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal = - error("agentik-cli native target is not implemented yet (prompt=$prompt)") - - actual fun homeDir(): String? = null - - actual fun env(key: String): String? = null - - actual fun sessionIo(): SessionIo = NoopSessionIo -} - -/** Минимальный no-op-IO для native-целей пока не подключён реальный движок. */ -private object NoopSessionIo : SessionIo { - override fun readAll(path: String): String? = null - override fun writeAtomic(path: String, body: String) {} - override fun delete(path: String) {} -} diff --git a/client/build.gradle.kts b/client/build.gradle.kts index db83f7b..bf28513 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -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). } diff --git a/client/src/main/kotlin/pw/binom/agentik/client/AgentClient.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt similarity index 85% rename from client/src/main/kotlin/pw/binom/agentik/client/AgentClient.kt rename to client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt index ac3ee35..839c754 100644 --- a/client/src/main/kotlin/pw/binom/agentik/client/AgentClient.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentClient.kt @@ -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)) + } } } } diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt new file mode 100644 index 0000000..8c54720 --- /dev/null +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/AgentikAgent.kt @@ -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]. + */ diff --git a/client/src/main/kotlin/pw/binom/agentik/client/ConversationClient.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/ConversationClient.kt similarity index 81% rename from client/src/main/kotlin/pw/binom/agentik/client/ConversationClient.kt rename to client/src/commonMain/kotlin/pw/binom/agentik/client/ConversationClient.kt index 390fcb9..ca92c72 100644 --- a/client/src/main/kotlin/pw/binom/agentik/client/ConversationClient.kt +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/ConversationClient.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)) + } } } diff --git a/client/src/main/kotlin/pw/binom/agentik/client/Dto.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/Dto.kt similarity index 100% rename from client/src/main/kotlin/pw/binom/agentik/client/Dto.kt rename to client/src/commonMain/kotlin/pw/binom/agentik/client/Dto.kt diff --git a/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt new file mode 100644 index 0000000..ed6ee55 --- /dev/null +++ b/client/src/commonMain/kotlin/pw/binom/agentik/client/HttpClientFactory.kt @@ -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) } +} diff --git a/client/src/main/kotlin/pw/binom/agentik/client/Serialization.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/Serialization.kt similarity index 100% rename from client/src/main/kotlin/pw/binom/agentik/client/Serialization.kt rename to client/src/commonMain/kotlin/pw/binom/agentik/client/Serialization.kt diff --git a/client/src/main/kotlin/pw/binom/agentik/client/Sse.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/Sse.kt similarity index 100% rename from client/src/main/kotlin/pw/binom/agentik/client/Sse.kt rename to client/src/commonMain/kotlin/pw/binom/agentik/client/Sse.kt diff --git a/client/src/main/kotlin/pw/binom/agentik/client/SseTimeout.kt b/client/src/commonMain/kotlin/pw/binom/agentik/client/SseTimeout.kt similarity index 100% rename from client/src/main/kotlin/pw/binom/agentik/client/SseTimeout.kt rename to client/src/commonMain/kotlin/pw/binom/agentik/client/SseTimeout.kt diff --git a/client/src/test/kotlin/pw/binom/agentik/client/SseTimeoutTest.kt b/client/src/commonTest/kotlin/pw/binom/agentik/client/SseTimeoutTest.kt similarity index 100% rename from client/src/test/kotlin/pw/binom/agentik/client/SseTimeoutTest.kt rename to client/src/commonTest/kotlin/pw/binom/agentik/client/SseTimeoutTest.kt diff --git a/client/src/main/kotlin/pw/binom/agentik/client/AgentikAgent.kt b/client/src/main/kotlin/pw/binom/agentik/client/AgentikAgent.kt deleted file mode 100644 index 7f92e37..0000000 --- a/client/src/main/kotlin/pw/binom/agentik/client/AgentikAgent.kt +++ /dev/null @@ -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) } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f528444..bb3dab7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,8 +13,9 @@ jvector = "3.0.6" text-embedding-kmp = "3.0.0-SNAPSHOT" kotlin-logging = "3.0.5" logback = "1.5.18" -jline = "3.30.0" mosaic = "0.18.0" +clikt = "5.0.3" +kotlinx-cli = "0.3.6" [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } @@ -54,6 +55,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" } @@ -61,8 +63,12 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" } # --- Model Context Protocol (MCP) --- mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" } -# --- CLI: JLine (readline для JVM-таргета) --- -jline = { module = "org.jline:jline", version.ref = "jline" } +# --- CLI: clikt (ajalt). KMP, native Linux/macOS/Windows включая linuxArm64. --- +# https://ajalt.github.io/clikt/ +# Артефакт один и тот же — `com.github.ajalt.clikt:clikt` — Gradle module +# metadata резолвит per-target variant (clikt-jvm / clikt-linuxarm64 / ...). +clikt = { module = "com.github.ajalt.clikt:clikt-core", version.ref = "clikt" } +kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinx-cli" } # --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. --- # https://github.com/JakeWharton/mosaic