7 Commits

Author SHA1 Message Date
subochev 65e05612a1 refactor(agentik-cli): вложенные subcommands (conv ls/new/...)
ci / JVM build + tests (push) Failing after 2m0s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 5m35s
- conv-ls/new/show/delete/rename -> вложенные под agentik-cli conv
- ConvCommand — Subcommand-родитель, регистрирует 5 дочерних
  команд в init { subcommands(...) }
- ConvSubcommand(name, description) extends AgentikSubcommand —
  базовый класс для всех conv-подкоманд (наследует --server/--id)

Два гоччаса kotlinx.cli 0.3.6 которые пришлось обойти:

1. parent.execute() вызывается ПОСЛЕ leaf.execute() всегда когда
   leaf достигнут через parent. Если parent делает что-то в
   execute() — вывод дублируется после каждой дочерней команды.
   Фикс: ConvCommand.execute() = Unit (no-op). Дочерние команды
   смотрятся через 'agentik-cli conv --help'.

2. По умолчанию 'conv new --server ...' парсится как
   conv[--server ...] + позиционный arg 'new' на уровне
   родителя, и дочерняя команда не запускается. Фикс:
   ArgParser(strictSubcommandOptionsOrder = true) — все аргументы
   после имени subcommand передаются в его парсер.

Smoke (linuxX64 kexe + JVM fatjar): conv ls/new/rename/show/delete
+ msgs/send/interrupt/info работают.
2026-09-18 00:29:48 +03:00
subochev 850ee99cb6 feat(agentik-cli): native-таргеты (linuxX64, macosX64/Arm64, mingwX64)
ci / JVM build + tests (push) Failing after 2m3s
- Добавил нативные таргеты с реальной реализацией (не stub-ы):
  - linuxX64 kexe ~5 МБ — собран, запускается, проходит
    smoke против 192.168.76.166 (--help, info, conv-ls,
    conv-new, send со стримом response-events, AGENTIK_SERVER
    env-переменная).
  - mingwX64 .exe ~6 МБ — собирается через кросс-компиляцию с Linux.
  - macosX64 / macosArm64 — на Linux-хосте не линкуются (нужен
    macOS-раннер, Apple Mach-O), но target-объявления + entryPoint
    валидны.
- entryPoint на K/N — FQN без 'Kt': pw.binom.agentik.cli.main
  (на JVM по-прежнему AgentikCliKt через mainClass.set).
- platformEnv: expect/actual split. Native actual — getenv()
  из platform.posix через kotlinx.cinterop, помеченный
  @OptIn(ExperimentalForeignApi::class).
- linuxArm64 у :agentik-cli отсутствует — kotlinx.cli 0.3.6 не
  публикует klib для linuxArm64. У :client linuxArm64 сохранён
  (асимметрия допустима: :client нужен только :agentik-cli,
  который на linuxArm64 не работает).
- README обновлён: target matrix, env-vars, native entry-point,
  платформенные детали.
2026-09-18 00:01:59 +03:00
subochev b5b21d146a feat(agentik-cli): one-shot subcommand CLI; client: streaming SSE via prepareGet
ci / JVM build + tests (push) Failing after 2m11s
- :agentik-cli переписан с REPL на one-shot subcommands:
  conv-ls / conv-new / conv-show / conv-delete / conv-rename /
  msgs / send / interrupt / info. Аргумент-парсер — kotlinx.cli 0.3.6
  (clikt 5.x отвергнут из-за upstream-бага duplicate symbol
  selfAndAncestors между clikt и clikt-mordant, issue #598).
- :client: events() переведён с httpClient.get() на
  prepareGet()+execute{} — get() дожидается полного тела, а SSE
  не закрывается никогда, поэтому подписка висела вечно. (Это
  же объясняет, почему TUI agent.events() фактически был
  нерабочим на реальном сервере.)
- :client KMP-конверсия (jvm + 5 desktop-native) уже была в
  коммите 9d826a4, здесь она просто подтверждена в статусе
  green по всем таргетам.
- REPL-инфраструктура (CliPlatform, EventRenderer, Main,
  SessionRepository, SlashCommand + 3 теста) удалена.
- agentik-cli/README переписан под subcommand-формат,
  root README обновлён (убран дубликат строки, agentik-tui
  убран из 'Запускаемые модули').

Smoke (на 192.168.76.166): info / conv-ls / conv-new /
conv-rename / conv-show / conv-delete / msgs / send
(стримит response-events до event End) / interrupt
(выводит event Interrupted).
2026-09-17 23:05:25 +03:00
subochev ee0b9d8341 build: исключаем :agentik-tui из сборки
ci / JVM build + tests (push) Failing after 1m25s
Пользователь признал TUI-подход неудачным (Mosaic 0.18 требует alt-screen
костылей, нативный ввод/вывод ограничен, тестирование через pty).

Папка agentik-tui/ оставлена на диске — комментарий в settings.gradle.kts
фиксирует дату и причину, на случай если вернёмся.

Изменения:
- settings.gradle.kts: include(':agentik-tui') → закомментировано
- build.gradle.kts: убран из moduleDescriptions
- .gitea/workflows/ci.yml: убран shadowJar шаг и из upload paths
- .gitea/workflows/release.yml: убран из комментария
- README.md, proto/README.md, server/README.md, client/README.md:
  ссылки на :agentik-tui помечены как устаревшие
- agentik-cli/build.gradle.kts: убрана ссылка в комментарии
2026-09-17 14:45:49 +03:00
subochev 9d826a4e81 fix(client): отключаем request/connect/socket-таймауты для SSE-стримов
ci / JVM build + tests (push) Failing after 1m23s
Дефолтный CIOEngineConfig.requestTimeout = 15 с убивал SSE-стрим при
простое, потому что движок CIO не считает запрос SSE-шным (мы читаем
bodyAsChannel() руками, без SSEClientContent). На TUI это проявлялось как
'стрим отвалился через 15 с' — события молча переставали приходить.

Два уровня фикса:

1. Per-request: HttpRequestBuilder.noSseReadTimeout() ставит capability
   HttpTimeoutCapability со всеми таймаутами = INFINITE_TIMEOUT_MS.
   В ConversationClient.events() и AgentClient.events() вызывается перед
   каждым SSE-стримом. Плагин HttpTimeout (если установлен) читает эту
   capability через ?: и не перезаписывает её.

2. Default client: defaultAgentikHttpClient() ставит
   engine { requestTimeout = 0 } — defense-in-depth на случай, если
   кто-то соберёт свой HttpClient без capability.

Тесты:
- SseTimeoutTest запускает встроенный Ktor CIO-сервер, держит stream 17 с.
- 'with noSseReadTimeout' — stream живёт до 'done' (тест проходит ~17 с).
- 'without noSseReadTimeout' — клиент падает на ~15 с с
  HttpRequestTimeoutException (контр-тест, доказывает что баг был).
2026-09-17 14:39:01 +03:00
subochev db3c49099c refactor(agentik-tui): вынести UI-компоненты в отдельный ui/ пакет
ci / JVM build + tests (push) Failing after 1m27s
Каждый composable — свой файл. App.kt оставлен только под корневую
композицию и глобальный key-handler.

- ui/Header.kt        — Header (идентификатор + focus label)
- ui/HistoryPanel.kt  — HistoryPanel + renderMessage (форматирование TuiMessage)
- ui/InputLine.kt     — InputLine + handleInputKey (key-handler строки ввода)
- ui/Footer.kt        — Footer (подсказка клавиш)
- ui/HelpOverlay.kt   — HelpOverlay (F1-список)

Bonus-чистка: убрал неиспользуемый collectAsState для historyScroll
(значение читалось, но никак не влияло на рендер — отдельный scroll
viewport запланирован отдельным изменением).

Размер App.kt: 154 → 61 строк. Каждый компонент <70 строк, импорты
локализованы в файле. 6 desktop-таргетов компилируется, jvmTest 10/10.
2026-09-17 13:53:33 +03:00
subochev ddd9d076c1 feat(agentik-tui): TuiBackend, health-check, unit-тесты
ci / JVM build + tests (push) Failing after 1m25s
Закрывает разрыв между :proto и UI-композицией: TuiBackend маршрутизирует
onUserMessage → Conversation.send и Event → AppState.

Изменения:
- agentik-tui/.../TuiBackend.kt — новый commonMain-файл (138 строк):
  инкапсулирует Agent-общение, авто-создание первого диалога,
  подписку на Conversation.events, диспетчеризацию Event в AppState.
- agentik-tui/.../Main.kt — обязательный health-check GET {baseUrl}/health
  ДО старта UI: понятная ошибка и exit 1 при недоступном сервере,
  понятное сообщение при не-200/не-'ok'. JVM-only API (java.net.*,
  ktor.*Timeout) обёрнуты в catch (Exception) — commonMain собирается
  под все desktop-native.
- agentik-tui/.../AppState.kt — добавлены attachBackend/setConversation/
  newConversation/postSystem; submitInput теперь не пишет AssistantStreaming
  сам (его рисует TuiBackend по Event.AppendText).
- agentik-tui/.../TuiApp.kt — TuiBackend монтируется в LaunchedEffect,
  делит scope с recomposer'ом.
- agentik-tui/.../Platform.jvm.kt — expect/actual platformEnv + platformCreateAgent.
- agentik-tui/.../Platform.native.kt — stub actual.
- agentik-tui/build.gradle.kts — kotlinx-coroutines-test в commonTest.
- agentik-cli/build.gradle.kts — binaries.executable entryPoint для native
  (тот же фикс, что прошёл для agentik-tui в предыдущем коммите).
- TuiBackend.dispatch: Event.End теперь зовёт finishAssistant()
  (конвертирует streaming-чанк в финальный Assistant), Interrupted —
  finishAssistant + 'прервано' system message. Раньше оба только
  выключали streaming, и последний чанк висел как AssistantStreaming
  с курсором.

Тесты: agentik-tui/src/commonTest/.../TuiBackendTest.kt — 10 кейсов
против FakeAgent/FakeConversation: auto-create, переиспользование,
AppendText-coalesce, End finalize, Interrupted system, ToolCall/ToolResult
visibility, Error handling, exception path, StartReasoning, connect
message. Используется runTest.backgroundScope + runCurrent — backgroundScope
не двигается через advanceUntilIdle (документированное поведение).

Сборка: jvm + linuxX64 + linuxArm64 + macosX64 + macosArm64 + mingwX64,
10/10 jvmTest green, full project jvmTest не задет.
2026-09-17 00:30:06 +03:00
60 changed files with 1665 additions and 1503 deletions
+4 -14
View File
@@ -65,25 +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: Build :agentik-tui shadowJar
shell: bash
run: |
./gradlew :agentik-tui:shadowJar \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
test -f agentik-tui/build/libs/agentik-tui-all.jar \
&& echo "shadowJar OK: $(du -h agentik-tui/build/libs/agentik-tui-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
agentik-tui/build/libs/agentik-tui-all.jar
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: 7
+2 -1
View File
@@ -1,7 +1,8 @@
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки # Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine". # (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
# #
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli, :agentik-tui) # Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов # НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
# выполняется локально из исходников (или руками через `./gradlew # выполняется локально из исходников (или руками через `./gradlew
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API # :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
+4 -8
View File
@@ -21,8 +21,8 @@ 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-tui/ Compose-for-Mosaic TUI-клиент (desktop) к /agentik ├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками └── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
``` ```
@@ -70,10 +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
# TUI
java --enable-native-access=ALL-UNNAMED -jar agentik-tui-0.1.0-all.jar
# curl # curl
curl http://localhost:8080/health curl http://localhost:8080/health
@@ -83,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), JVM + 4 native.
- [`: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`.
+146 -53
View File
@@ -1,83 +1,176 @@
# `:agentik-cli` — JVM CLI клиент к `/agentik` # `:agentik-cli` — one-shot CLI клиент к `/agentik`
## Что это ## Что это
JVM-only REPL-клиент к серверу `:standalone` через `:client` **One-shot subcommand CLI** (Kotlin Multiplatform) к серверу
над HTTP+SSE: `:standalone` через `: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.
| Платформа | Артефакт | Размер | Статус |
|---|---|---|---|
| `jvm` (JRE 21) | `*-all.jar` | ~7 МБ | ✓ собирается и работает |
| `linuxX64` | `.kexe` | ~5 МБ | ✓ собирается и работает на этом хосте |
| `macosX64` | `.kexe` | — | собирается на macOS-раннере |
| `macosArm64` | `.kexe` | — | собирается на macOS-arm64-раннере |
| `mingwX64` | `.exe` | ~6 МБ | ✓ собирается (cross-compile с Linux) |
| `linuxArm64` | — | — | **нет** — kotlinx-cli 0.3.6 не публикует klib для linuxArm64 |
| `iOS` | — | — | нет смысла на iOS |
## Подкоманды
```
agentik-cli <command> [args...]
Команды верхнего уровня:
conv <subcommand> операции над диалогами (см. ниже)
msgs <id> [--limit N] показать сообщения
send <id> <text...> отправить ход, стримит response-события в stdout
interrupt <id> прервать текущий ход
info показать конфиг (server URL + agent id)
Подкоманды `conv`:
conv ls список диалогов
conv new [--temp] создать диалог, печатает id
conv show <id> метаданные диалога
conv delete <id> удалить диалог
conv rename <id> <title> переименовать
```
`--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 "привет"
agentik-cli info # через AGENTIK_SERVER env-переменную
```
## Как запустить ## Как запустить
### Требования ### JVM (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 \ ./gradlew :agentik-cli:shadowJar
--server http://192.168.76.166:8080/agentik java --enable-native-access=ALL-UNNAMED \
-jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar conv --help
``` ```
### Запуск через Gradle (dev) ### Native linuxX64
```bash ```bash
./gradlew :agentik-cli:run --args="--server http://localhost:8080/agentik" ./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
``` ```
## Параметры CLI ### Native macOS / Windows
| Флаг | ENV | Что делает | На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
|---|---|---| macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) | кросс-компиляцию.
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
| `--help` | — | Показывает help и выходит |
## Slash-команды (внутри REPL) CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
:agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
раннере Gitea Actions.
| Команда | Синонимы | Что делает | ## Примеры
|---|---|---|
| `/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`) ```bash
# Список диалогов (таблица)
agentik-cli conv ls --server http://localhost:8080/agentik
См. [`../standalone/README.md`](../standalone/README.md). На стороне # Создать диалог
клиента они **не** интерпретируются — это лишь настройки запуска ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
агента. CLI только знает, по какому URL стучаться. echo "new conv: $ID"
## Известное ограничение # Переименовать
agentik-cli conv rename --server http://localhost:8080/agentik "$ID" "мой чат"
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на # Отправить ход и стримить ответ
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
terminal. Это upstream-особенность Ktor SSE.
# Показать последние N сообщений
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
# Прервать активный ход
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
# Удалить
agentik-cli conv delete --server http://localhost:8080/agentik "$ID"
# Через env-переменную
AGENTIK_SERVER=http://localhost:8080/agentik agentik-cli info
```
## Формат вывода `send`
Каждое SSE-событие печатается отдельной строкой `event <Type> ...` —
пригодно для парсинга через `awk`/`jq`-обёртки:
```
event StartReasoning
event StartResponse TEXT
event AppendText \n\n
event AppendText Привет!
event End
```
Терминальные события (`End`, `Interrupted`, `Error`) тоже
печатаются; CLI выходит сразу после `End`.
## Почему kotlinx.cli (а не clikt)
- **kotlinx.cli 0.3.6** (JetBrains, KMP) — единственный зрелый
arg-parser, который стабильно линкуется под `linux_x64` +
`macos_x64`/`macos_arm64` + `mingw_x64`. Минус: нет `linux_arm64`.
- **clikt-multiplatform 5.x** (ajalt) — имеет linuxArm64, но
ломается на native linker: `duplicate symbol selfAndAncestors`
между `clikt` и `clikt-mordant` commonMain (issue
[ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598)).
Workaround `kotlin.native.cacheKind.linuxX64=none` замедляет
сборку на порядки и не решает проблему до конца. Поэтому clikt
отвергнут.
## Платформенные детали
- **entryPoint на K/N** — это FQN функции **без** суффикса `Kt`
(т.е. `pw.binom.agentik.cli.main`, а не `MainKt.main`). JVM
convention `MainKt.main` тут не работает — K/N линкер ищет
функцию по `package.main`.
- **`platformEnv(key)`** для чтения env-переменных:
- JVM: `System.getenv(key)` через `jvmMain` actual.
- Native: `getenv(key)` из `platform.posix` через
`kotlinx.cinterop.toKString()` (`nativeMain` actual,
требует `@OptIn(ExperimentalForeignApi::class)`).
- **Stdout / exit code** — работают на K/N через корутины.
## Готчасы kotlinx.cli
- **Вложенные subcommands + parent.execute().** В kotlinx.cli 0.3.6
`parent.execute()` вызывается ПОСЛЕ `leaf.execute()` всегда,
когда leaf был достигнут через parent. Поэтому `ConvCommand.execute()`
сделан no-op (`override fun execute() = Unit`), иначе вывод
дочерней команды дублируется выводом родителя. Дочерние команды
смотрятся через `agentik-cli conv --help`.
- **strictSubcommandOptionsOrder.** Без этого флага `conv new --server ...`
парсится как `conv [--server ...]` + позиционный аргумент `new`
на уровне родителя — и дочерняя команда не запускается.
В `ArgParser` сразу включается `strictSubcommandOptionsOrder = true`.
## Тесты ## Тесты
``` Тесты для подкоманд пока не написаны (TODO). Базовый smoke
./gradlew :agentik-cli:jvmTest покрывается руками против живого сервера.
```
23 теста: парсер slash-команд, event-рендер, state-repository. ```bash
./gradlew :agentik-cli:jvmTest # 0/0 — пока пусто
```
## Версии ## Версии
+44 -52
View File
@@ -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,67 +11,67 @@ plugins {
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
// Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9, // Native-таргеты, которые покрывает kotlinx.cli 0.3.6 (см. его .module
// но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть. // в Maven Central): linux_x64, macos_x64, macos_arm64, mingw_x64.
compilerOptions { // linuxArm64 не входит — kotlinx.cli 0.3.6 для него не публикуется
freeCompilerArgs.add("-Xexpect-actual-classes") // (последний релиз 2023-09, KMP-targets зафиксированы). clikt-multiplatform
} // 5.x имеет linuxArm64, но ломается на duplicate symbol `selfAndAncestors`
// между clikt и clikt-mordant при линковке native (issue ajalt/clikt#598),
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto. // поэтому clikt отвергнут.
// commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only) //
// и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual, // iOS не входит: :agentik-cli бессмыслен на iOS, а :client (единственный
// расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь. // его потребитель) тоже без iOS.
jvm() jvm()
macosX64() listOf(
macosArm64() linuxX64(),
iosX64() macosX64(),
iosArm64() macosArm64(),
iosSimulatorArm64() mingwX64(),
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 — commonMain-only (нет 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")
} }
} }
} }
// entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
// (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
// функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
// mainClass.set(...) выше.
listOf(
linuxX64(),
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")
@@ -80,20 +79,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.ConvCommand
import kotlinx.coroutines.launch import pw.binom.agentik.cli.commands.InfoSubcommand
import kotlinx.coroutines.runBlocking import pw.binom.agentik.cli.commands.InterruptSubcommand
import pw.binom.agentik.proto.Agent import pw.binom.agentik.cli.commands.MsgsSubcommand
import pw.binom.agentik.proto.Content import pw.binom.agentik.cli.commands.SendSubcommand
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import kotlin.time.Instant
/** /**
* Главный класс 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|new|show|delete|rename операции над диалогами
* msgs <id> [--limit N] показать сообщения
* send <id> <text...> отправить ход, стримит response-события в stdout
* interrupt <id> прервать текущий ход
* info показать конфиг
*
* `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е.
* `agentik-cli conv ls --server http://...`), не до — kotlinx.cli не
* шарит опции родителя в subcommand.
*
* Вложенные subcommands (`conv ls`, `conv new`, ...) реализованы
* через [Subcommand.subcommands]: `conv` сам — subcommand, и его
* дочерние команды (`ls`, `new`, `show`, `delete`, `rename`)
* регистрируются у него.
*/
@OptIn(ExperimentalCli::class)
fun runAgentikCli(args: Array<String>) {
val parser = ArgParser(
programName = "agentik-cli",
// Все аргументы после имени subcommand должны передаваться
// В subcommand-парсер, а не парситься на уровне родителя.
// Без этого `conv new --server ...` парсится как `conv [--server ...]`
// + аргумент "new" → execute родителя, без вложенной команды.
strictSubcommandOptionsOrder = true,
) )
private var currentConv: Conversation? = null val conv = ConvCommand()
private var currentTitle: String? = null parser.subcommands(
private var lastEventAt: Instant = Instant.DISTANT_PAST conv,
MsgsSubcommand(),
SendSubcommand(),
InterruptSubcommand(),
InfoSubcommand(),
)
private val scope = CoroutineScope(Dispatchers.Default) parser.parse(args)
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 expect fun platformEnv(key: String): String?
@@ -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
}
@@ -0,0 +1,32 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ExperimentalCli
import kotlinx.cli.Subcommand
import pw.binom.agentik.cli.AgentikSubcommand
/**
* Родительская группа `conv`: операции над диалогами.
*
* Сама команда `agentik-cli conv` (без подкоманды) — no-op:
* в kotlinx.cli parent.execute() вызывается ПОСЛЕ leaf.execute(),
* поэтому любая работа в execute() дублирует вывод дочерней команды.
* Для просмотра дочерних команд есть `agentik-cli conv --help`.
*
* Дочерние команды регистрируются через [subcommands] в конструкторе.
*/
@OptIn(ExperimentalCli::class)
class ConvCommand : Subcommand("conv", "Операции над диалогами") {
init {
subcommands(
ConvLsSubcommand(),
ConvNewSubcommand(),
ConvShowSubcommand(),
ConvDeleteSubcommand(),
ConvRenameSubcommand(),
)
}
override fun execute() = Unit
}
abstract class ConvSubcommand(name: String, description: String) : AgentikSubcommand(name, description)
@@ -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 : ConvSubcommand("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 : ConvSubcommand("ls", "Список диалогов агента") {
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val 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 : ConvSubcommand("new", "Создать диалог; печатает id") {
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.createConversation(temp = temp)
println(conv.id)
}
}
@@ -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 : ConvSubcommand("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 : ConvSubcommand("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")
}
}
@@ -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() }
}
}
@@ -0,0 +1,3 @@
package pw.binom.agentik.cli
internal actual fun platformEnv(key: String): String? = System.getenv(key)
@@ -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) {}
}
@@ -0,0 +1,8 @@
package pw.binom.agentik.cli
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
import platform.posix.getenv
@OptIn(ExperimentalForeignApi::class)
internal actual fun platformEnv(key: String): String? = getenv(key)?.toKString()
+23
View File
@@ -24,6 +24,23 @@ kotlin {
linuxArm64() linuxArm64()
mingwX64() mingwX64()
// Native executables. По умолчанию Kotlin/Native для каждого target'а
// собирает только .klib (библиотеку) — для запускаемого .kexe надо
// явно попросить binaries.executable(). entryPoint нужно задать явно:
// KMP-линкер ищет функцию по FQN (без `Kt`-суффикса), а Kotlin/Native
// добавляет суффикс только для файлов с именем `Main.kt`, поэтому
// указываем точку входа как `pw.binom.agentik.tui.main` (без суффикса).
//
// Применяем к каждому из linuxX64/macosX64/macosArm64/linuxArm64/mingwX64
// явно (а не через targets.withType), потому что targets DSL в KMP не
// поддерживает реифицированный withType<KotlinNativeTarget>().
@OptIn(ExperimentalKotlinGradlePluginApi::class)
listOf(linuxX64(), linuxArm64(), macosX64(), macosArm64(), mingwX64()).forEach {
it.binaries.executable {
entryPoint = "pw.binom.agentik.tui.main"
}
}
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(project(":proto")) implementation(project(":proto"))
@@ -34,6 +51,11 @@ kotlin {
// JetBrains Compose runtime — тащит Mosaic как обёртку. // JetBrains Compose runtime — тащит Mosaic как обёртку.
implementation(libs.mosaic.runtime) implementation(libs.mosaic.runtime)
implementation(libs.mosaic.tty.terminal) implementation(libs.mosaic.tty.terminal)
// Health-check в Main.kt: Ktor CIO на JVM, на native не собирается —
// там работает stub actual через expect/actual.
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
} }
jvmMain.dependencies { jvmMain.dependencies {
implementation(project(":client")) implementation(project(":client"))
@@ -41,6 +63,7 @@ kotlin {
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.test)
} }
} }
@@ -3,18 +3,19 @@ package pw.binom.agentik.tui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.layout.KeyEvent
import com.jakewharton.mosaic.layout.drawBehind
import com.jakewharton.mosaic.layout.onPreviewKeyEvent import com.jakewharton.mosaic.layout.onPreviewKeyEvent
import com.jakewharton.mosaic.modifier.Modifier import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Box
import com.jakewharton.mosaic.ui.Column import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Row import com.jakewharton.mosaic.ui.Row
import com.jakewharton.mosaic.ui.Text import pw.binom.agentik.tui.ui.Footer
import com.jakewharton.mosaic.ui.TextStyle import pw.binom.agentik.tui.ui.Header
import pw.binom.agentik.tui.ui.HelpOverlay
import pw.binom.agentik.tui.ui.HistoryPanel
import pw.binom.agentik.tui.ui.InputLine
/** /**
* Корневая Compose-композиция TUI. * Корневая Compose-композиция TUI. Содержит только каркас + глобальный key-handler;
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
* *
* Layout (минимальный): * Layout (минимальный):
* ``` * ```
@@ -28,6 +29,9 @@ import com.jakewharton.mosaic.ui.TextStyle
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │ * │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
* └─────────────────────────────────────────────────────────┘ * └─────────────────────────────────────────────────────────┘
* ``` * ```
*
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
*/ */
@Composable @Composable
internal fun App(state: AppState) { internal fun App(state: AppState) {
@@ -50,105 +54,8 @@ internal fun App(state: AppState) {
Header(state, focusIndex) Header(state, focusIndex)
HistoryPanel(state) HistoryPanel(state)
InputLine(state) InputLine(state)
Footer(state, showHelp) Footer(showHelp)
} }
} }
if (showHelp) HelpOverlay() if (showHelp) HelpOverlay()
} }
@Composable
private fun Header(state: AppState, focusIndex: Int) {
val title by state.currentTitle.collectAsState()
val convId by state.currentConversationId.collectAsState()
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
val titleStr = title ?: "(нет диалога)"
Text(
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
textStyle = TextStyle.Bold + TextStyle.Invert,
)
}
@Composable
private fun HistoryPanel(state: AppState) {
val messages by state.messages.collectAsState()
val scroll by state.historyScroll.collectAsState()
val rendered = if (messages.isEmpty()) {
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
} else {
messages.joinToString("") { renderMessage(it) }
}
Text(value = rendered)
}
private fun renderMessage(m: TuiMessage): String = when (m) {
is TuiMessage.System -> " ── ${m.text}\n"
is TuiMessage.User -> " > ${m.text}\n"
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
}
@Composable
private fun InputLine(state: AppState) {
val text by state.input.collectAsState()
val cursor by state.cursor.collectAsState()
val streaming by state.streaming.collectAsState()
val cursorPos = cursor.coerceIn(0, text.length)
val before = text.substring(0, cursorPos)
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
val after = text.substring(afterStart.coerceAtMost(text.length))
val prompt = if (streaming) " ⋯" else " >"
Text(
value = "$prompt $before|$cursorChar|${after}",
modifier = Modifier
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
.drawBehind {
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
state.input.let { /* touch */ }
},
)
}
private fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
if (ev.alt || ev.ctrl) return false
return when (ev.key) {
"Enter" -> state.submitInput() != null
"Backspace" -> { state.inputBackspace(); true }
"Delete" -> { state.inputDelete(); true }
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
"Home" -> { state.inputCursorHome(); true }
"End" -> { state.inputCursorEnd(); true }
else -> {
val s = ev.key
if (s.length == 1) { state.inputInsert(s); true }
else false
}
}
}
@Composable
private fun Footer(state: AppState, showHelp: Boolean) {
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
Text(value = hint, textStyle = TextStyle.Italic)
}
@Composable
private fun HelpOverlay() {
Column(modifier = Modifier) {
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
Text(value = " ↑ / ↓ скролл истории / курсор в input")
Text(value = " ← / → курсор в input")
Text(value = " Enter отправить сообщение")
Text(value = " Backspace / Del удалить символ")
Text(value = " Esc очистить input")
Text(value = " Ctrl-D / Ctrl-C выход")
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
}
}
@@ -15,6 +15,10 @@ import kotlin.time.Instant
* (см. samples/snake в репо Mosaic). * (см. samples/snake в репо Mosaic).
*/ */
internal class AppState(val config: TuiConfig) { internal class AppState(val config: TuiConfig) {
/** Бэкенд, прикреплённый из TuiApp — маршрутизирует submitInput → send. */
private var backend: TuiBackend? = null
fun attachBackend(b: TuiBackend) { backend = b }
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */ /** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
private val _focusIndex = MutableStateFlow(0) private val _focusIndex = MutableStateFlow(0)
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow() val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
@@ -101,9 +105,28 @@ internal class AppState(val config: TuiConfig) {
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant()) _messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
inputClear() inputClear()
_streaming.value = true _streaming.value = true
backend?.onUserMessage(text)
return text return text
} }
fun setStreaming(v: Boolean) { _streaming.value = v }
fun setConversation(id: String, title: String?) {
_currentConversationId.value = id
_currentTitle.value = title
_messages.value = emptyList()
_historyScroll.value = 0
_streaming.value = false
}
fun postToolCall(toolName: String, title: String?, args: String) {
_messages.value = _messages.value + TuiMessage.ToolCall(toolName = toolName, title = title, args = args, ts = nowInstant())
}
fun postToolResult(toolName: String, result: String) {
_messages.value = _messages.value + TuiMessage.ToolResult(toolName = toolName, result = result, ts = nowInstant())
}
fun appendAssistant(chunk: String) { fun appendAssistant(chunk: String) {
val list = _messages.value.toMutableList() val list = _messages.value.toMutableList()
val last = list.lastOrNull() val last = list.lastOrNull()
@@ -1,6 +1,12 @@
package pw.binom.agentik.tui package pw.binom.agentik.tui
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.Agent
/** /**
* Точка входа TUI-клиента agentik. * Точка входа TUI-клиента agentik.
@@ -9,14 +15,62 @@ import kotlinx.coroutines.runBlocking
* agentik-tui [--server URL] [--id ID] [--no-history] [--help] * agentik-tui [--server URL] [--id ID] [--no-history] [--help]
* ``` * ```
* *
* Без аргументов — стартует Compose-Mosaic UI. * Перед запуском UI — обязательный health-check: `GET {server}/health`.
* Если сервер недоступен — печатаем понятную ошибку и выходим с кодом 1.
* Если OK — создаём [Agent] через платформенную actual и запускаем
* [TuiApp].
*/ */
fun main(args: Array<String>) = runBlocking { fun main(args: Array<String>) = runBlocking {
val cfg = parseCliArgs(args) ?: run { val cfg = parseCliArgs(args) ?: run {
printUsage() printUsage()
return@runBlocking return@runBlocking
} }
TuiApp(cfg).run() checkServer(cfg.server)
val agent = platformCreateAgent(cfg.server, cfg.id)
TuiApp(cfg, agent).run()
}
/**
* Делает синхронный GET `{baseUrl}/health`. Внутри [route(path)] на сервере
* `/health` зарегистрирован под тем же path-prefix'ом, что и сам API
* (например, baseUrl = `http://localhost:8080/agentik` → health = …/agentik/health).
*
* При любой ошибке (connect refused, timeout, не-200 ответ, не `"ok"`) —
* бросает [IllegalStateException] с понятным сообщением. [runBlocking]-обёртка
* в [main] разворачивает её в stack-trace и `exit 1`.
*/
private suspend fun checkServer(baseUrl: String) {
val healthUrl = "${baseUrl.trimEnd('/')}/health"
val client = HttpClient(CIO) {
install(HttpTimeout) {
requestTimeoutMillis = 5_000
connectTimeoutMillis = 3_000
}
expectSuccess = false
}
try {
val response = client.get(healthUrl)
if (response.status.value !in 200..299) {
throw IllegalStateException("сервер ответил HTTP ${response.status.value} на GET $healthUrl")
}
val body = response.bodyAsText().trim()
if (body != "ok") {
throw IllegalStateException("сервер ответил неожиданным телом на GET $healthUrl: '$body'")
}
} catch (e: IllegalStateException) {
throw e
} catch (e: Exception) {
// На JVM сюда упадут java.net.ConnectException, UnknownHostException,
// io.ktor.client.network.sockets.ConnectTimeoutException и т.п.
// На нативе native stub падает раньше в platformCreateAgent, так что
// сюда мы попадём только под JVM-actual.
throw IllegalStateException(
"ошибка health-check $healthUrl: ${e::class.simpleName} — ${e.message ?: "(нет сообщения)"}",
e,
)
} finally {
client.close()
}
} }
/** /**
@@ -70,6 +124,12 @@ private fun parseCliArgs(args: Array<String>): TuiConfig? {
*/ */
internal expect fun platformEnv(key: String): String? internal expect fun platformEnv(key: String): String?
/**
* Создаёт платформенную реализацию [Agent]. JVM actual подключает `:client`
* и ходит в HTTP-фасад; native actual пока возвращает stub (см. Platform.native.kt).
*/
internal expect fun platformCreateAgent(baseUrl: String, id: String): Agent
private fun printUsage() { private fun printUsage() {
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik" val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon" val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
@@ -86,11 +146,15 @@ private fun printUsage() {
--no-history не сохранять состояние --no-history не сохранять состояние
--help, -h эта справка --help, -h эта справка
Переменные среды:
AGENTIK_SERVER базовый URL (эквивалент --server)
USER / USERNAME используется в id клиента по умолчанию
В UI: В UI:
Tab / Shift-Tab переключить фокус между историей и вводом Tab / Shift-Tab переключить фокус между историей и вводом
↑ / ↓ скроллить историю / двигать курсор в инпуте ↑ / ↓ скроллить историю / двигать курсор в инпуте
← / → двинуть курсор в инпуте ← / → двинуть курсор в инпуте
Enter отправить сообщение Enter отправить сообщение (создаст новый диалог, если их нет)
Ctrl-C / Ctrl-D выйти Ctrl-C / Ctrl-D выйти
F1 показать подсказки по горячим клавишам F1 показать подсказки по горячим клавишам
""".trimIndent()) """.trimIndent())
@@ -1,27 +1,31 @@
package pw.binom.agentik.tui package pw.binom.agentik.tui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.jakewharton.mosaic.runMosaicBlocking import com.jakewharton.mosaic.runMosaicBlocking
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import kotlin.coroutines.CoroutineContext
/** /**
* Корневая точка запуска UI. Стартует Mosaic-рантайм и ждёт завершения приложения. * Корневая точка запуска UI. Стартует Mosaic-рантайм, монтирует [TuiBackend] в
* его coroutine-scope и ждёт завершения приложения.
* *
* По дизайну — singleton: все остальные модули (UI, бэкенд-корутины) живут внутри * Бэкенд — единый singleton на процесс: UI-композиция, сетевые подписки и
* одной Compose-композиции и пользуются её [CoroutineScope]. * coroutine job'ы делят scope [runMosaicBlocking] (через [LaunchedEffect]).
*
* Реальный бэкенд (TuiBackend) подключается в следующем коммите: сейчас
* стартует на пустом [Agent]-заглушке для smoke-теста.
*/ */
internal class TuiApp(private val config: TuiConfig) { internal class TuiApp(
private val config: TuiConfig,
private val agent: Agent,
) {
fun run() { fun run() {
runMosaicBlocking { runMosaicBlocking {
val state = remember { AppState(config) } val state = remember { AppState(config) }
val backend = remember { TuiBackend(state = state, agent = agent) }
LaunchedEffect(backend) {
backend.start(this)
}
state.attachBackend(backend)
App(state = state) App(state = state)
} }
} }
@@ -0,0 +1,138 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import kotlin.coroutines.CoroutineContext
import kotlin.time.Instant
/**
* Backend-логика TUI: мост между [Agent] и [AppState].
*
* Жизненный цикл:
* 1. На старте [start] — health-check сделан в [Main] ДО Mosaic; здесь только
* пост-сообщение "connected to …".
* 2. Подписка на [Agent.events] — обновление списка диалогов в sidebar.
* 3. При [onUserMessage] — если текущего диалога нет, создаём
* [createConversation] (temp=false, чтобы он персистился на сервере), затем
* [send]. Подписка на [Conversation.events] идёт сразу при создании/открытии.
*
* Дизайн: один backend-объект на процесс, живёт в [runMosaicBlocking]-scope.
*/
internal class TuiBackend(
private val state: AppState,
private val agent: Agent,
) {
/** Текущий открытый диалог, либо `null`, если ещё не выбран. */
private var current: Conversation? = null
/** Активная джоба подписки на [Conversation.events]. */
private var eventsJob: Job? = null
/** Последний виденный момент событий — для переподписки при reconnect. */
private var lastSeenAt: Instant = Instant.DISTANT_PAST
/**
* Запускает фоновые подписки в scope [scope] (передаётся из Mosaic
* LaunchedEffect'а — это scope recomposer'а, живёт до закрытия UI).
*/
fun start(scope: CoroutineScope) {
this.scope = scope
state.postSystem("подключено к ${state.config.server}")
scope.launch {
try {
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
} catch (_: kotlinx.coroutines.CancellationException) {
// штатная отмена при закрытии UI
} catch (e: Exception) {
state.postSystem("ошибка live-events: ${e.message ?: e::class.simpleName}")
}
}
}
private lateinit var scope: CoroutineScope
/**
* Обработка пользовательского сообщения, отправленного из input.
*
* Если текущего диалога нет — создаём его; затем `send`. Подписка на
* события конкретного диалога стартует в [ensureConversation].
*/
fun onUserMessage(text: String) {
scope.launch {
try {
val conv = ensureConversation()
conv.send(listOf(Content.Text(text)))
} catch (e: Exception) {
state.postSystem("ошибка отправки: ${e.message ?: e::class.simpleName}")
state.setStreaming(false)
}
}
}
/**
* Создаёт [Conversation], если ещё не было; открывает подписку на её события.
*/
private suspend fun ensureConversation(): Conversation {
current?.let { return it }
val conv = agent.createConversation(temp = false)
state.setConversation(id = conv.id, title = conv.title)
subscribeEvents(conv, Instant.DISTANT_PAST)
current = conv
return conv
}
/**
* Подписывается на [Conversation.events] и перенаправляет их в [state].
*/
private fun subscribeEvents(conv: Conversation, from: Instant) {
eventsJob?.cancel()
eventsJob = scope.launch {
conv.events(from).collect { ev -> dispatch(ev) }
}
}
/**
* Маппинг [Event] → [AppState] (что показать в TUI).
*
* - AppendText → дописывает в последний ассистентский чанк
* - StartReasoning / StartResponse → новый streaming-чанк
* - End → закрывает streaming
* - Interrupted → закрывает streaming + системное сообщение
* - ToolCall / ToolResult → сообщения в историю
* - Error → системное сообщение
*/
private fun dispatch(ev: Event) {
lastSeenAt = ev.date
when (ev) {
is Event.AppendText -> state.appendAssistant(ev.body)
is Event.StartReasoning -> {
state.postSystem("… думаю")
}
is Event.StartResponse -> state.setStreaming(true)
is Event.End -> state.finishAssistant()
is Event.Interrupted -> {
state.finishAssistant()
state.postSystem("прервано")
}
is Event.AppendImage -> {
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
}
is Event.ToolCall -> {
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
}
is Event.ToolResult -> {
state.postToolResult(toolName = "", result = ev.result ?: "")
}
is Event.Error -> {
state.setStreaming(false)
state.postSystem("ошибка: ${ev.message}")
}
}
}
}
@@ -0,0 +1,17 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.TextStyle
/**
* Нижняя подсказка с текущим набором горячих клавиш.
*
* При открытом help-оверлее показывает заглушку с указателем «наверху».
*/
@Composable
internal fun Footer(showHelp: Boolean) {
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
Text(value = hint, textStyle = TextStyle.Italic)
}
@@ -0,0 +1,26 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.TextStyle
import pw.binom.agentik.tui.AppState
/**
* Верхняя инвертированная полоса с идентификатором и текущим фокусом.
*
* Пример: ` agentik · cli-tui:root · a1b2c3d4… · мой чат · focus=input `
*/
@Composable
internal fun Header(state: AppState, focusIndex: Int) {
val title by state.currentTitle.collectAsState()
val convId by state.currentConversationId.collectAsState()
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
val titleStr = title ?: "(нет диалога)"
Text(
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
textStyle = TextStyle.Bold + TextStyle.Invert,
)
}
@@ -0,0 +1,26 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.TextStyle
/**
* Полноэкранный оверлей со списком горячих клавиш.
* Включается/выключается по F1 (см. [pw.binom.agentik.tui.App]).
*/
@Composable
internal fun HelpOverlay() {
Column(modifier = Modifier) {
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
Text(value = " ↑ / ↓ скролл истории / курсор в input")
Text(value = " ← / → курсор в input")
Text(value = " Enter отправить сообщение")
Text(value = " Backspace / Del удалить символ")
Text(value = " Esc очистить input")
Text(value = " Ctrl-D / Ctrl-C выход")
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
}
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.ui.Text
import pw.binom.agentik.tui.AppState
import pw.binom.agentik.tui.TuiMessage
/**
* Прокручиваемый (через клавиатуру) лог диалога.
* Каждое сообщение рендерится отдельной строкой с префиксом (см. [renderMessage]).
* При пустом списке показывается подсказка.
*/
@Composable
internal fun HistoryPanel(state: AppState) {
val messages by state.messages.collectAsState()
val rendered = if (messages.isEmpty()) {
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
} else {
messages.joinToString("") { renderMessage(it) }
}
Text(value = rendered)
}
/** Превращает [TuiMessage] в одну строку с префиксом. Потоковые чанки получают курсор `▍`. */
internal fun renderMessage(m: TuiMessage): String = when (m) {
is TuiMessage.System -> " ── ${m.text}\n"
is TuiMessage.User -> " > ${m.text}\n"
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
}
@@ -0,0 +1,67 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.layout.KeyEvent
import com.jakewharton.mosaic.layout.drawBehind
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Text
import pw.binom.agentik.tui.AppState
/**
* Нижняя строка ввода с курсором.
* При активном стриме ассистента показывает ` ⋯`, иначе ` >`.
*
* Содержимое строки: `<prompt> <before>|<cursorChar>|<after>` —
* `cursorChar` — это символ, на котором стоит курсор (или пробел в конце).
*
* Клавиши обрабатываются через [handleInputKey] внутри `onPreviewKeyEvent`.
*/
@Composable
internal fun InputLine(state: AppState) {
val text by state.input.collectAsState()
val cursor by state.cursor.collectAsState()
val streaming by state.streaming.collectAsState()
val cursorPos = cursor.coerceIn(0, text.length)
val before = text.substring(0, cursorPos)
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
val after = text.substring(afterStart.coerceAtMost(text.length))
val prompt = if (streaming) " ⋯" else " >"
Text(
value = "$prompt $before|$cursorChar|${after}",
modifier = Modifier
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
.drawBehind {
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
state.input.let { /* touch */ }
},
)
}
/**
* Обработка клавиш в [InputLine]. `true` = событие поглощено.
*
* Не перехватывает клавиши с `alt`/`ctrl` — они идут дальше
* на корневой обработчик ([pw.binom.agentik.tui.App]).
*/
internal fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
if (ev.alt || ev.ctrl) return false
return when (ev.key) {
"Enter" -> state.submitInput() != null
"Backspace" -> { state.inputBackspace(); true }
"Delete" -> { state.inputDelete(); true }
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
"Home" -> { state.inputCursorHome(); true }
"End" -> { state.inputCursorEnd(); true }
else -> {
val s = ev.key
if (s.length == 1) { state.inputInsert(s); true }
else false
}
}
}
@@ -0,0 +1,85 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant
/**
* Минимальный fake [Agent] для тестов [TuiBackend]: считает, сколько раз
* вызвали [createConversation], и отдаёт заранее сконструированные
* [FakeConversation].
*/
internal class FakeAgent(
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
) : Agent {
override val id: String = "fake"
var createCount: Int = 0
private set
val conversations = mutableListOf<FakeConversation>()
override fun createConversation(temp: Boolean): Conversation {
createCount++
val c = conversationFactory()
conversations += c
return c
}
override suspend fun getConversation(id: String): Conversation? =
conversations.firstOrNull { it.id == id }
override suspend fun deleteConversation(id: String): Boolean =
conversations.removeAll { it.id == id }
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
conversations.toList()
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
}
/**
* [Conversation], запоминающий все вызовы [send] и эмитящий управляемые
* [Event] через общий [MutableSharedFlow]. Используется в тестах
* [TuiBackend] для проверки маршрутизации событий в UI.
*/
internal class FakeConversation(
override val id: String = "fake-conv",
override val title: String? = null,
) : Conversation {
override val isSupportImageInput: Boolean = false
override val isSupportImageOutput: Boolean = false
override val isTemporal: Boolean = false
override val updatedAt: Instant = Instant.DISTANT_PAST
val sent = mutableListOf<List<Content>>()
val sentContexts = mutableListOf<MessageContext?>()
var closed: Boolean = false
private set
var interrupted: Boolean = false
private set
private val eventsFlow = MutableSharedFlow<Event>(extraBufferCapacity = 64)
fun emit(e: Event) { eventsFlow.tryEmit(e) }
override suspend fun rename(title: String) = Unit
override suspend fun send(content: List<Content>, context: MessageContext?) {
sent += content
sentContexts += context
}
override suspend fun interrupt() { interrupted = true }
override fun events(after: Instant): Flow<Event> = eventsFlow
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
override fun close() { closed = true }
}
@@ -0,0 +1,249 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Тесты [TuiBackend]. Используем [runTest.backgroundScope] (а не TestScope)
* для передачи в `start` — фоновые подписки должны жить параллельно с
* телом теста и автоматически отменяться по его завершении. Иначе
* бесконечный collect на `agent.events()` завешивает runTest на 60s
* `UncompletedCoroutinesError`.
*
* [runCurrent] нужен после каждого `onUserMessage` и каждого `emit`,
* потому что `backgroundScope` использует свой диспетчер, который не
* продвигается через `advanceUntilIdle` — `runCurrent` прогоняет ровно
* те задачи, что готовы к запуску сейчас.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TuiBackendTest {
private fun fixtureConfig(server: String = "http://localhost:8080/agentik") =
TuiConfig(server = server, id = "cli-tui:tester", historyEnabled = true)
@Test
fun `start posts connected system message`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent()
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(
sysMsgs.any { it.text.contains(cfg.server) },
"ожидалось системное 'подключено к ${cfg.server}', было: ${sysMsgs.map { it.text }}",
)
}
@Test
fun `first onUserMessage auto-creates conversation with temp=false`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent()
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("привет")
runCurrent()
assertEquals(1, agent.createCount, "должен быть один createConversation")
assertEquals(listOf("привет"), agent.conversations.first().sent.flattenText())
// temp=false — обычный (не временный) диалог: персистится на сервере
assertFalse(agent.conversations.first().isTemporal, "диалог не должен быть временным")
// state знает id и title нового диалога
assertEquals("fake-conv", state.currentConversationId.value)
}
@Test
fun `second onUserMessage reuses same conversation`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent()
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("раз")
runCurrent()
backend.onUserMessage("два")
runCurrent()
assertEquals(1, agent.createCount, "новый диалог создавать не должны — переиспользуем старый")
assertEquals(2, agent.conversations.first().sent.size)
}
@Test
fun `AppendText appends to current assistant streaming chunk`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.AppendText(now, "Привет"))
conv.emit(Event.AppendText(now, ", мир"))
runCurrent()
val assistantMsgs = state.messages.value.filterIsInstance<TuiMessage.AssistantStreaming>()
assertEquals(1, assistantMsgs.size, "должен быть один streaming-чанк, не два")
assertEquals("Привет, мир", assistantMsgs.single().text)
assertTrue(state.streaming.value)
}
@Test
fun `End event finalizes assistant and stops streaming`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.AppendText(now, "ответ"))
conv.emit(Event.End(now))
runCurrent()
val last = state.messages.value.last()
assertTrue(last is TuiMessage.Assistant, "после End последнее сообщение должно стать финальным Assistant, было: ${last::class.simpleName}")
assertEquals("ответ", (last as TuiMessage.Assistant).text)
assertFalse(state.streaming.value)
}
@Test
fun `Interrupted event clears streaming and posts system message`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.AppendText(now, "часть ответа"))
conv.emit(Event.Interrupted(now))
runCurrent()
assertFalse(state.streaming.value)
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(
sysMsgs.any { it.text.contains("прервано") },
"ожидалось 'прервано' в системных сообщениях, было: ${sysMsgs.map { it.text }}",
)
}
@Test
fun `ToolCall and ToolResult events become visible tool messages`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
runCurrent()
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
val resultMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolResult>()
assertEquals(1, toolMsgs.size)
assertEquals("echo", toolMsgs.single().toolName)
assertEquals("""{"x":1}""", toolMsgs.single().args)
assertEquals(1, resultMsgs.size)
assertEquals("ok", resultMsgs.single().result)
}
@Test
fun `Error event posts system message and clears streaming`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.Error(date = now, message = "boom"))
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(sysMsgs.any { it.text.contains("boom") }, "должно быть 'ошибка: boom'")
assertFalse(state.streaming.value)
}
@Test
fun `onUserMessage does not swallow exceptions — state stays consistent`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(
sysMsgs.any { it.text.contains("ошибка отправки") || it.text.contains("server kaboom") },
"должна быть системная ошибка, было: ${sysMsgs.map { it.text }}",
)
assertFalse(state.streaming.value, "стриминг должен быть выключен в catch-ветке")
}
@Test
fun `StartReasoning posts thinking system message`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartReasoning(now))
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(sysMsgs.any { it.text.contains("думаю") })
}
}
private fun List<List<Content>>.flattenText(): List<String> =
map { cs -> cs.filterIsInstance<Content.Text>().joinToString("") { it.body } }
@@ -4,11 +4,9 @@ import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
/** /**
* Платформенная фабрика [Agent]. JVM-only пока: native не подключали ktor-движки. * Платформенные actual'ы для JVM. Используется `:client` поверх Ktor CIO.
*/ */
internal actual fun platformEnv(key: String): String? = System.getenv(key) internal actual fun platformEnv(key: String): String? = System.getenv(key)
/** internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
* Реализация [TuiApp.createAgent] для JVM — обычный ktor-cio через `:client`. AgentikAgent(id = id, baseUrl = baseUrl)
*/
internal fun jvmCreateAgent(baseUrl: String, id: String): Agent = AgentikAgent(id = id, baseUrl = baseUrl)
@@ -9,5 +9,5 @@ import pw.binom.agentik.proto.Agent
*/ */
internal actual fun platformEnv(key: String): String? = null internal actual fun platformEnv(key: String): String? = null
internal fun nativeCreateAgent(baseUrl: String, id: String): Agent = internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)") error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
+1 -1
View File
@@ -51,7 +51,7 @@ val moduleDescriptions: Map<String, String> = mapOf(
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).", "storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.", "agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.", "agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
"agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (desktop, без iOS) с клавиатурной навигацией без ':'-префиксов.", // "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.", "standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
) )
rootProject.extra.set("moduleDescriptions", moduleDescriptions) rootProject.extra.set("moduleDescriptions", moduleDescriptions)
+1 -1
View File
@@ -19,7 +19,7 @@ JVM, iOS, macOS, Linux, Windows.
## Где используется ## Где используется
- `:agentik-cli` — REPL. - `:agentik-cli` — REPL.
- `:agentik-tui` — Compose-for-Mosaic клиент. - `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
- Любой внешний KMP-проект, который хочет встроить агента в свой UI. - Любой внешний KMP-проект, который хочет встроить агента в свой UI.
## Как подключить ## Как подключить
+43 -18
View File
@@ -1,25 +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)
} }
@@ -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,13 +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() }
check(response.status == HttpStatusCode.OK) { .execute { response ->
"events: server returned ${response.status}" check(response.status == HttpStatusCode.OK) {
} "events: server returned ${response.status}"
readSse(response.bodyAsChannel()) }
.collect { payload -> readSse(response.bodyAsChannel())
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload)) .collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
} }
} }
} }
@@ -1,9 +1,6 @@
package pw.binom.agentik.client package pw.binom.agentik.client
import io.ktor.client.HttpClient 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 import pw.binom.agentik.proto.Agent
/** /**
@@ -24,8 +21,8 @@ import pw.binom.agentik.proto.Agent
* агента не знает, поэтому клиент должен её знать сам (или взять из * агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига). * конфига).
* *
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON + * [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация. * движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
*/ */
fun AgentikAgent( fun AgentikAgent(
id: String, id: String,
@@ -34,10 +31,16 @@ fun AgentikAgent(
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id) ): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
/** /**
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и * Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер * живёт в общем коде и плагина `SSEClientContent` не требует.
* (см. [readSse]) живёт в общем коде и плагина не требует. *
* **Платформы:**
* - 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].
*/ */
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(agentikJson) }
}
@@ -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,13 +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` дожидается полного
check(response.status == HttpStatusCode.OK) { // тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
"events: server returned ${response.status}" // вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
} httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
readSse(response.bodyAsChannel()) .execute { response ->
.collect { payload -> check(response.status == HttpStatusCode.OK) {
emit(agentikJson.decodeFromString(Event.serializer(), payload)) "events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
} }
} }
@@ -0,0 +1,18 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
/**
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
*
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
engine { requestTimeout = 0 }
install(ContentNegotiation) { json(agentikJson) }
}
@@ -0,0 +1,31 @@
package pw.binom.agentik.client
import io.ktor.client.plugins.HttpTimeoutConfig
import io.ktor.client.plugins.HttpTimeoutCapability
import io.ktor.client.request.HttpRequestBuilder
/**
* Отключает request/connect/socket-таймауты для конкретного запроса через
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
*
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
*
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
* установленном capability мутирует его поля через `?:`, так что шаренный
* инстанс мог бы утечь между запросами.
*/
internal fun HttpRequestBuilder.noSseReadTimeout() {
setCapability(
HttpTimeoutCapability,
HttpTimeoutConfig(
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
),
)
}
@@ -0,0 +1,148 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.request.header
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondBytesWriter
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.http.ContentType
import io.ktor.utils.io.writeStringUtf8
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.net.ServerSocket
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
* перед каждым read-стримом.
*
* Тест запускает встроенный Ktor CIO-сервер на свободном порту. Сервер шлёт
* "hello", ждёт 20 с (дольше дефолтного requestTimeout = 15 с), затем шлёт
* "done". Без capability клиент отвалился бы на ~15 с; с capability — второе
* сообщение доходит.
*
* Читаем строки пока не найдём "data: done" или пока не сработает
* [withTimeout] (18 с — запас над server delay 20 с).
*/
class SseTimeoutTest {
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
@Test
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing {
get("/sse") {
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
writeStringUtf8("data: hello\n\n")
flush()
// 17 с — чуть больше дефолтного CIO requestTimeout = 15 с.
// Если capability сломана, клиент упадёт на 15 с и не получит "done".
delay(17_000)
writeStringUtf8("data: done\n\n")
}
}
}
}.start(wait = false)
try {
val client = HttpClient(CIO)
val received = mutableListOf<String>()
client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream")
noSseReadTimeout()
}.execute { resp ->
val ch = resp.bodyAsChannel()
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
// Если capability сломана, клиент упадёт на 15 с и мы словим исключение.
val deadline = 19_000L
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < deadline) {
val line = withTimeout<String?>(deadline) { ch.readUTF8Line() } ?: break
if (line.startsWith("data: ")) {
received.add(line)
}
if (line == "data: done") break
}
}
assertTrue(received.contains("data: hello"), "должно получить hello: $received")
assertTrue(
received.contains("data: done"),
"должно получить done (SSE read не должен падать на 15 с): $received",
)
assertFalse(
received.any { it == "<timeout>" },
"SSE read упал в timeout (capability не сработал): $received",
)
} finally {
server.stop(100, 200)
}
}
/**
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
* Сервер держит stream 17 с; если клиент не выставил capability —
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
* дожидаясь "done".
*/
@Test
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing {
get("/sse") {
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
writeStringUtf8("data: hello\n\n")
flush()
delay(17_000)
writeStringUtf8("data: done\n\n")
}
}
}
}.start(wait = false)
try {
val client = HttpClient(CIO)
val start = System.currentTimeMillis()
try {
client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream")
// НАМЕРЕННО без noSseReadTimeout.
}.execute { resp ->
val ch = resp.bodyAsChannel()
// Читаем строки, пока не придёт "data: done" — без capability
// клиент упадёт на ~15 с до того, как сервер пошлёт done.
while (true) {
val line = ch.readUTF8Line() ?: break
if (line == "data: done") break
}
}
fail("без capability клиент должен словить HttpRequestTimeoutException")
} catch (e: HttpRequestTimeoutException) {
val elapsed = System.currentTimeMillis() - start
assertTrue(
elapsed in 14_000..17_000,
"timeout должен сработать в районе 15 с (default), elapsed=$elapsed",
)
}
} finally {
server.stop(100, 200)
}
}
}
+9 -3
View File
@@ -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
+2 -2
View File
@@ -22,8 +22,8 @@
- `:server` — Ktor-фасад, маппит `Agent` ↔ HTTP/SSE. - `:server` — Ktor-фасад, маппит `Agent` ↔ HTTP/SSE.
- `:client` — Ktor-клиент, маппит HTTP/SSE ↔ `Agent/Conversation`. - `:client` — Ktor-клиент, маппит HTTP/SSE ↔ `Agent/Conversation`.
- `:agentik-cli`, `:agentik-tui` — оба работают поверх `:client`, - `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`.
а следовательно поверх `:proto`. *(`:agentik-tui` был исключён из сборки 2026-09-17.)*
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает - `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
`Message`/`Event` напрямую через storage. `Message`/`Event` напрямую через storage.
+1 -1
View File
@@ -20,7 +20,7 @@ IRC/MCP) общаясь с одним сервером по стабильном
- `:standalone` подключает `Route.agentikAgent(agent)` в свой - `:standalone` подключает `Route.agentikAgent(agent)` в свой
embedded Netty engine. embedded Netty engine.
- Любые клиенты (наши `:client`, `:agentik-cli`, `:agentik-tui`, или - Любые клиенты (наши `:client`, `:agentik-cli`, или
внешние web-фронтенды) идут через этот контракт. внешние web-фронтенды) идут через этот контракт.
## Как подключить ## Как подключить
+3 -1
View File
@@ -41,7 +41,9 @@ include(":client")
include(":agentik-cli") include(":agentik-cli")
// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton), // TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton),
// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios). // рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios).
include(":agentik-tui") // include(":agentik-tui") — отключено 2026-09-17: пользователь признал TUI-подход неудачным.
// Папка agentik-tui/ оставлена на диске для возможного возврата; из сборки исключена.
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы, // Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
// `:memory-md` — реализация на базе §-файлов (Hermes-style). // `:memory-md` — реализация на базе §-файлов (Hermes-style).
include(":memory-api") include(":memory-api")