feat(agentik-cli): one-shot subcommand CLI; client: streaming SSE via prepareGet
ci / JVM build + tests (push) Failing after 2m11s
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:
@@ -65,15 +65,15 @@ jobs:
|
|||||||
./gradlew :agentik-cli:shadowJar \
|
./gradlew :agentik-cli:shadowJar \
|
||||||
-Dorg.gradle.jvmargs=-Xmx4096M \
|
-Dorg.gradle.jvmargs=-Xmx4096M \
|
||||||
--no-daemon --no-watch-fs --stacktrace
|
--no-daemon --no-watch-fs --stacktrace
|
||||||
test -f 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)"
|
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
|
||||||
|
|
||||||
- name: Upload shadowJars
|
- name: Upload shadowJars
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: agentik-jars
|
name: agentik-jars
|
||||||
path: |
|
path: |
|
||||||
standalone/build/libs/standalone-all.jar
|
standalone/build/libs/standalone-*-all.jar
|
||||||
agentik-cli/build/libs/agentik-cli-all.jar
|
agentik-cli/build/libs/agentik-cli-*-all.jar
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ agentik/
|
|||||||
├── storage-inmemory/ in-memory реализация для тестов и Android
|
├── storage-inmemory/ in-memory реализация для тестов и Android
|
||||||
├── storage-sqlite/ SQLite реализация для production
|
├── storage-sqlite/ SQLite реализация для production
|
||||||
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
|
||||||
├── agentik-cli/ JVM REPL-клиент (JLine) к /agentik
|
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
|
||||||
├── agentik-cli/ JVM + 8 native CLI-клиент к /agentik (REPL)
|
|
||||||
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
|
||||||
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
|
||||||
```
|
```
|
||||||
@@ -71,7 +70,7 @@ java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CLI
|
# 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
|
||||||
curl http://localhost:8080/health
|
curl http://localhost:8080/health
|
||||||
@@ -81,8 +80,7 @@ curl http://localhost:8080/health
|
|||||||
|
|
||||||
- Запускаемые:
|
- Запускаемые:
|
||||||
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
|
||||||
- [`:agentik-cli`](agentik-cli/README.md) — REPL-клиент (JLine).
|
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli).
|
||||||
- [`:agentik-tui`](agentik-tui/README.md) — Compose-for-Mosaic TUI.
|
|
||||||
- Библиотеки (контракты и реализации):
|
- Библиотеки (контракты и реализации):
|
||||||
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
- [`:proto`](proto/README.md) — stateful KMP-протокол.
|
||||||
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
|
||||||
|
|||||||
+90
-51
@@ -2,83 +2,122 @@
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
JVM-only REPL-клиент к серверу `:standalone` через `:client`
|
JVM-only **one-shot subcommand CLI** к серверу `:standalone` через
|
||||||
над HTTP+SSE:
|
`:client` по HTTP+SSE. Один вызов — одна команда: стримы ответа
|
||||||
|
`send` идёт в stdout построчно, никакого embedded-REPL.
|
||||||
|
|
||||||
- Нативный REPL с JLine (стрелки влево/вправо/вверх, история,
|
Решает: быстрый способ дёрнуть агента из shell-скрипта или руками,
|
||||||
Ctrl-D/E).
|
не поднимая отдельную TUI-сессию.
|
||||||
- Подписка на live-стрим событий агента.
|
|
||||||
- Slash-команды: `/new /list /switch /rename /rm /interrupt /history
|
|
||||||
/pwd /help /exit /quit`.
|
|
||||||
- Persistent session id в `~/.agentik/cli-state.json`.
|
|
||||||
|
|
||||||
Решает: быстрый способ проверить агента руками из терминала.
|
## Подкоманды
|
||||||
Используется в CI-смоук-тестах и для daily-driver.
|
|
||||||
|
```
|
||||||
|
agentik-cli <subcommand> [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 "привет"
|
||||||
|
```
|
||||||
|
|
||||||
## Как запустить
|
## Как запустить
|
||||||
|
|
||||||
### Требования
|
### Из готового fatjar
|
||||||
|
|
||||||
- JVM 21+ (на машине должна быть JAVA_HOME или `java` в PATH).
|
|
||||||
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
|
|
||||||
|
|
||||||
### Запуск из готового fatjar
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-all.jar \
|
java --enable-native-access=ALL-UNNAMED \
|
||||||
--server http://192.168.76.166:8080/agentik
|
-jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Запуск через Gradle (dev)
|
### Через Gradle
|
||||||
|
|
||||||
```bash
|
```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 | Что делает |
|
```bash
|
||||||
|---|---|---|
|
# Список диалогов (таблица)
|
||||||
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
|
agentik-cli conv-ls --server http://localhost:8080/agentik
|
||||||
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
|
|
||||||
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
|
|
||||||
| `--help` | — | Показывает help и выходит |
|
|
||||||
|
|
||||||
## Slash-команды (внутри REPL)
|
# Создать диалог
|
||||||
|
ID=$(agentik-cli conv-new --server http://localhost:8080/agentik)
|
||||||
|
echo "new conv: $ID"
|
||||||
|
|
||||||
| Команда | Синонимы | Что делает |
|
# Переименовать
|
||||||
|---|---|---|
|
agentik-cli conv-rename --server http://localhost:8080/agentik "$ID" "мой чат"
|
||||||
| `/help` | | Показывает help |
|
|
||||||
| `/new [title]` | | Создать диалог |
|
|
||||||
| `/list` | `/ls` | Список диалогов |
|
|
||||||
| `/switch <id>` | `/sw`, `/cd` | Переключиться на диалог |
|
|
||||||
| `/rename <title>` | | Переименовать текущий диалог |
|
|
||||||
| `/rm [id]` | `/delete` | Удалить (текущий или по id) |
|
|
||||||
| `/interrupt` | `/stop`, `/cancel` | Прервать текущий ход |
|
|
||||||
| `/history` | `/h`, `/hist` | Показывает историю текущего диалога |
|
|
||||||
| `/pwd` | | Путь к state-file |
|
|
||||||
| `/exit`, `/quit` | | Выйти |
|
|
||||||
|
|
||||||
## Переменные среды (пробрасываются серверу через `--server`)
|
# Отправить ход и стримить ответ
|
||||||
|
agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
|
||||||
|
|
||||||
См. [`../standalone/README.md`](../standalone/README.md). На стороне
|
# Показать последние N сообщений
|
||||||
клиента они **не** интерпретируются — это лишь настройки запуска
|
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
|
||||||
агента. CLI только знает, по какому URL стучаться.
|
|
||||||
|
|
||||||
## Известное ограничение
|
# Прервать активный ход
|
||||||
|
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
|
||||||
|
|
||||||
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
|
# Удалить
|
||||||
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
|
agentik-cli conv-delete --server http://localhost:8080/agentik "$ID"
|
||||||
terminal. Это upstream-особенность Ktor SSE.
|
```
|
||||||
|
|
||||||
|
## Формат вывода `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
|
./gradlew :agentik-cli:jvmTest
|
||||||
```
|
```
|
||||||
|
|
||||||
23 теста: парсер slash-команд, event-рендер, state-repository.
|
|
||||||
|
|
||||||
## Версии
|
## Версии
|
||||||
|
|
||||||
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
|
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
|
||||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
import org.gradle.api.artifacts.ConfigurationContainer
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
@@ -12,79 +11,39 @@ plugins {
|
|||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
// Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9,
|
// Одноразовый CLI. Пока только JVM. Native-бинари добавим позже —
|
||||||
// но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть.
|
// kotlinx.cli 0.3.6 не имеет linuxArm64, а clikt имеет upstream-баг
|
||||||
compilerOptions {
|
// с duplicate symbol selfAndAncestors между clikt и clikt-mordant.
|
||||||
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} когда дойдёт очередь.
|
|
||||||
jvm()
|
jvm()
|
||||||
macosX64()
|
|
||||||
macosArm64()
|
|
||||||
iosX64()
|
|
||||||
iosArm64()
|
|
||||||
iosSimulatorArm64()
|
|
||||||
linuxX64()
|
|
||||||
linuxArm64()
|
|
||||||
mingwX64()
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":proto"))
|
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.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)
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
jvm {
|
jvm {
|
||||||
binaries {
|
binaries {
|
||||||
executable {
|
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) ---
|
// Fatjar — аналог :standalone.
|
||||||
//
|
|
||||||
// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`.
|
|
||||||
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register.
|
|
||||||
|
|
||||||
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||||
archiveBaseName.set("agentik-cli")
|
archiveBaseName.set("agentik-cli")
|
||||||
archiveClassifier.set("all")
|
archiveClassifier.set("all")
|
||||||
@@ -92,20 +51,13 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
|||||||
group = "build"
|
group = "build"
|
||||||
|
|
||||||
from(tasks.named("jvmJar"))
|
from(tasks.named("jvmJar"))
|
||||||
val cc = try {
|
from(project.configurations.getByName("jvmRuntimeClasspath"))
|
||||||
@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"))
|
|
||||||
|
|
||||||
mergeServiceFiles()
|
mergeServiceFiles()
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
|
||||||
manifest {
|
manifest {
|
||||||
attributes["Main-Class"] = "pw.binom.agentik.cli.MainKt"
|
attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt"
|
||||||
attributes["Implementation-Title"] = "agentik-cli"
|
attributes["Implementation-Title"] = "agentik-cli"
|
||||||
attributes["Implementation-Version"] = project.version.toString()
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,323 +1,87 @@
|
|||||||
package pw.binom.agentik.cli
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.cli.ArgParser
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.cli.ArgType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.cli.ExperimentalCli
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.cli.Subcommand
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.cli.default
|
||||||
import kotlinx.coroutines.isActive
|
import pw.binom.agentik.cli.commands.ConvDeleteSubcommand
|
||||||
import kotlinx.coroutines.launch
|
import pw.binom.agentik.cli.commands.ConvLsSubcommand
|
||||||
import kotlinx.coroutines.runBlocking
|
import pw.binom.agentik.cli.commands.ConvNewSubcommand
|
||||||
import pw.binom.agentik.proto.Agent
|
import pw.binom.agentik.cli.commands.ConvRenameSubcommand
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.cli.commands.ConvShowSubcommand
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.cli.commands.InfoSubcommand
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.cli.commands.InterruptSubcommand
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.cli.commands.MsgsSubcommand
|
||||||
import kotlin.time.Instant
|
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`.
|
||||||
*
|
*
|
||||||
* Управляет:
|
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
|
||||||
* - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]);
|
* `--server`/`--id` (иначе subcommand не видит значения родителя).
|
||||||
* - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer].
|
|
||||||
* - персистентностью сессии (восстановление последнего диалога при перезапуске CLI).
|
|
||||||
*
|
|
||||||
* Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения.
|
|
||||||
* `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий
|
|
||||||
* увидит [Event.Interrupted] и сам завершится.
|
|
||||||
*/
|
*/
|
||||||
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(
|
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
|
||||||
historyFile = if (config.historyEnabled) stateFilePath() else null,
|
*
|
||||||
prompt = "agentik> ",
|
* ```
|
||||||
)
|
* agentik-cli <command> [args...]
|
||||||
private val sessionRepo = SessionRepository(
|
*
|
||||||
filePath = if (config.historyEnabled) stateFilePath() else null,
|
* Commands:
|
||||||
io = CliPlatform.sessionIo(),
|
* 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
|
parser.parse(args)
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package pw.binom.agentik.cli
|
||||||
|
|
||||||
|
internal fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+15
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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]"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() }
|
|
||||||
}
|
|
||||||
@@ -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) {}
|
|
||||||
}
|
|
||||||
+43
-27
@@ -1,34 +1,50 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
jvmToolchain(21)
|
||||||
jvmTarget.set(JvmTarget.JVM_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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// :client — это библиотека, не executable. Native-бинари объявляются
|
||||||
dependencies {
|
// в :agentik-cli (он зависит от :client и реально предоставляет main).
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-9
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
@@ -66,15 +67,15 @@ internal class AgentClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
override fun events(after: Instant): Flow<AgentEvent> = flow {
|
||||||
val response = httpClient.get("$agentUrl/events?after=$after") {
|
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
|
||||||
noSseReadTimeout()
|
.execute { response ->
|
||||||
}
|
check(response.status == HttpStatusCode.OK) {
|
||||||
check(response.status == HttpStatusCode.OK) {
|
"events: server returned ${response.status}"
|
||||||
"events: server returned ${response.status}"
|
}
|
||||||
}
|
readSse(response.bodyAsChannel())
|
||||||
readSse(response.bodyAsChannel())
|
.collect { payload ->
|
||||||
.collect { payload ->
|
emit(agentikJson.decodeFromString(AgentEvent.serializer(), 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].
|
||||||
|
*/
|
||||||
+13
-9
@@ -6,6 +6,7 @@ import io.ktor.client.request.get
|
|||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.request.patch
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
@@ -72,15 +73,18 @@ internal class ConversationClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun events(after: Instant): Flow<Event> = flow {
|
override fun events(after: Instant): Flow<Event> = flow {
|
||||||
val response = httpClient.get("$convUrl/events?after=$after") {
|
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
|
||||||
noSseReadTimeout()
|
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
|
||||||
}
|
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
|
||||||
check(response.status == HttpStatusCode.OK) {
|
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
|
||||||
"events: server returned ${response.status}"
|
.execute { response ->
|
||||||
}
|
check(response.status == HttpStatusCode.OK) {
|
||||||
readSse(response.bodyAsChannel())
|
"events: server returned ${response.status}"
|
||||||
.collect { payload ->
|
}
|
||||||
emit(agentikJson.decodeFromString(Event.serializer(), payload))
|
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) }
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,9 @@ jvector = "3.0.6"
|
|||||||
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
text-embedding-kmp = "3.0.0-SNAPSHOT"
|
||||||
kotlin-logging = "3.0.5"
|
kotlin-logging = "3.0.5"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
jline = "3.30.0"
|
|
||||||
mosaic = "0.18.0"
|
mosaic = "0.18.0"
|
||||||
|
clikt = "5.0.3"
|
||||||
|
kotlinx-cli = "0.3.6"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
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-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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", 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-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-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" }
|
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) ---
|
# --- Model Context Protocol (MCP) ---
|
||||||
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
|
||||||
|
|
||||||
# --- CLI: JLine (readline для JVM-таргета) ---
|
# --- CLI: clikt (ajalt). KMP, native Linux/macOS/Windows включая linuxArm64. ---
|
||||||
jline = { module = "org.jline:jline", version.ref = "jline" }
|
# 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. ---
|
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
|
||||||
# https://github.com/JakeWharton/mosaic
|
# https://github.com/JakeWharton/mosaic
|
||||||
|
|||||||
Reference in New Issue
Block a user