19 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
SubochevAV 7a47131f6f ci: release.yml — оставляем только публикацию KMP-библиотек в Nexus
ci / JVM build + tests (push) Failing after 1m24s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m43s
Удалён job build-fatjars + upload-artifact + Attach-to-release-API.
Сборка и прикрепление fatjar-ов делается локально (./gradlew
:<module>:shadowJar) и через Gitea UI/API руками. CICD занимается
только тем, что умеет: публикует библиотеки в Nexus caffeine.
2026-09-16 23:41:45 +03:00
SubochevAV 9196102f68 build: gradle.version default key — agentik.version.default (не 'version')
release / Build runnable fatjars (release) Successful in 2m59s
release / Publish KMP libraries → caffeine Nexus (release) Waiting to run
ci / JVM build + tests (push) Failing after 1m21s
Иначе -Pversion=$TAG от CICD не перебивает gradle.properties (Gradle-мерж
отдаёт приоритет default-ключу 'version'). Теперь:

  gradle.properties          → agentik.version.default=0.1.0-SNAPSHOT  (fallback)
  CICD -Pversion=$TAG       → реальная версия релиза (e.g. '1')

Проверено: -Pversion=1 → standalone-1-all.jar (а не -0.1.0-SNAPSHOT).
2026-09-16 22:50:25 +03:00
SubochevAV 6b12dd2c5b build: revert version=1 manual edit; release.yml + build.gradle.kts set it from tag
ci / JVM build + tests (push) Failing after 1m18s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 1m39s
release / Build runnable fatjars (release) Successful in 2m40s
2026-09-16 22:43:27 +03:00
SubochevAV eed1ab9a17 ci: replace forgejo-release action with direct API call to attach assets
ci / JVM build + tests (push) Failing after 1m20s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 1m13s
release / Build runnable fatjars (release) Successful in 3m39s
forgejo-release@v1 calls 'tea release create' which errors with
'There already is a release for this tag' when the release is
pre-created (which release.yml needs because the trigger is
'release.published').

Workaround: do the upload ourselves via POST /api/v1/repos/.../releases/{id}/assets
with binary body. This is what forgejo-release ends up doing internally
after it successfully creates the release.
2026-09-16 22:20:37 +03:00
SubochevAV b27ac622b4 ci: fix forgejo-release invocation — direction: upload + release-dir
ci / JVM build + tests (push) Failing after 1m17s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 1m10s
release / Build runnable fatjars (release) Failing after 2m43s
The action requires 'direction: upload' (was implicit) and a
'release-dir' path to scan. Without these it errors out with
'need upload or download argument got nothing'.
2026-09-16 22:03:08 +03:00
SubochevAV 14b46087dd ci: downgrade upload-artifact v4 -> v3 (Forgejo doesn't bundle @actions/artifact v2)
ci / JVM build + tests (push) Failing after 1m17s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 1m11s
release / Build runnable fatjars (release) Failing after 2m48s
2026-09-16 21:56:20 +03:00
SubochevAV 098c97c7bd ci: fix forgejo-release action URL — use code.forgejo.org/actions/forgejo-release@v1
release / Build runnable fatjars (release) Failing after 2m8s
ci / JVM build + tests (push) Failing after 1m27s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 1m17s
2026-09-16 21:49:51 +03:00
SubochevAV 4b8e5bb0bd ci: fix release.yml — use GITHUB_REF_NAME for tag version
ci / JVM build + tests (push) Failing after 32s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 4m48s
release / Build runnable fatjars (release) Failing after 4s
Forgejo (Gitea Actions engine) exposes env vars under GITHUB_-prefix,
not GITEA_-prefix. ${GITEA_REF_NAME} was empty → maven-publish
failed with "Invalid publication 'iosArm64': version cannot be empty".

Use $GITHUB_REF_NAME inside bash (with explicit TAG= assignment for
debug echo).
2026-09-16 21:41:24 +03:00
subochev d75289ac56 merge: per-module READMEs + root navigation + CI/CD fixes
ci / JVM build + tests (push) Failing after 31s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 18s
release / Build runnable fatjars (release) Failing after 3s
2026-09-16 20:47:10 +03:00
SubochevAV 05f7b8fd04 ci: fix fatjar file paths in ci.yml + release.yml
ci / JVM build + tests (pull_request) Failing after 32s
shadowJar produces `standalone-0.1.0-all.jar` (archiveBaseName +
classifier.all), not `standalone-all.jar`. Use `*-all.jar` glob
patterns in upload + attach steps; works regardless of version.

🤖 Generated with [opencode]
2026-09-16 20:46:54 +03:00
SubochevAV 8f616f359f docs: per-module READMEs (run vs library) + root navigation hub
ci / JVM build + tests (pull_request) Failing after 54s
Every subproject now has README.md:
- 3 runnable modules (:standalone, :agentik-cli, :agentik-tui):
  quickstart, env table, parameters, known limits
- 11 library modules: what it is, which problem solves, how to
  wire it in, where versions live

Root README.md is the navigation hub (Quickstart, Modules table,
publish + CI/CD notes).

Also: ci.yml prunes the :memory-vector -x excludes now that
text-embedding-kmp artifacts are published to caffeine.

518 tests green.

Verified publish pipeline: :proto:publish to caffeine produces
pom.module + per-target klibs + sources for all 9 KMP targets.

🤖 Generated with [opencode]
2026-09-16 20:44:16 +03:00
subochev 5ad972767d ci: trigger CI workflow to verify CICD setup end-to-end
ci / JVM build + tests (pull_request) Failing after 18s
2026-09-16 20:14:29 +03:00
75 changed files with 2757 additions and 2410 deletions
+11 -20
View File
@@ -1,9 +1,10 @@
# PR / push-build. Прогоняет unit-тесты на JVM и линтер gradle-плагинов. # PR / push-build. Прогоняет unit-тесты на JVM, линтер gradle-плагинов
# и проверяет, что shadowJar'ы запускаемых модулей собираются без ошибок.
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml. # Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
# #
# Требуемые Gitea Action Secrets: нет (только gradle-cache). # Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
# Опционально: GRADLE_DOWNLOAD_TOKEN — если хочется переиспользовать кэш между # Все env secrets доступны через vars/secrets репозитория — см. начало
# репами (через actions/cache + restore-keys). # release.yml для требуемых переменных.
name: ci name: ci
on: on:
@@ -55,8 +56,8 @@ jobs:
./gradlew :standalone:shadowJar \ ./gradlew :standalone:shadowJar \
-Dorg.gradle.jvmargs=-Xmx4096M \ -Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace --no-daemon --no-watch-fs --stacktrace
test -f standalone/build/libs/standalone-all.jar \ test -f standalone/build/libs/standalone-*-all.jar \
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-all.jar)" && echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
- name: Build :agentik-cli shadowJar - name: Build :agentik-cli shadowJar
shell: bash shell: bash
@@ -64,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
+21 -78
View File
@@ -1,10 +1,15 @@
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки # Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine", а также # (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
# собирает fatjar'ы запускаемых модулей и прикрепляет их к релизу как #
# бинарные ассеты. # Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
# выполняется локально из исходников (или руками через `./gradlew
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
# отдельно от этого workflow.
# #
# Требуемые Gitea Action Variables: # Требуемые Gitea Action Variables:
# BINOM_REPO_URL — например http://nexus.xx/repository/caffeine/ # BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/
# Требуемые Gitea Action Secrets: # Требуемые Gitea Action Secrets:
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию. # BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
name: release name: release
@@ -50,84 +55,22 @@ jobs:
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }} BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }} BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
run: | run: |
# Gitea Actions (Forgejo-based) экспонирует env-переменные под
# GITHUB_-префиксом: GITHUB_REF_NAME = "v0.1.0" для tag-trigger'а.
# Внутри bash подставляем через $GITHUB_REF_NAME (а не
# ${GITEA_REF_NAME} — Forgejo этого не подставляет).
#
# Версия = имя тега (с trim'ом опционального префикса 'v'), чтобы
# тег "1" публиковался как pw.binom.agentik:<module>:1. CICD не
# хардкодит версию — берёт её из тега каждый раз.
TAG="$GITHUB_REF_NAME"
VERSION="${TAG#v}"
echo "Publishing version: ${VERSION}"
./gradlew \ ./gradlew \
"-Pversion=${GITEA_REF_NAME}" \ "-Pversion=${VERSION}" \
"-Pbinom.repo.url=${BINOM_REPO_URL}" \ "-Pbinom.repo.url=${BINOM_REPO_URL}" \
"-Pbinom.repo.user=${BINOM_REPO_USER}" \ "-Pbinom.repo.user=${BINOM_REPO_USER}" \
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \ "-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
publish \ publish \
-Dorg.gradle.jvmargs=-Xmx4096M \ -Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace --no-daemon --no-watch-fs --stacktrace
build-fatjars:
name: Build runnable fatjars
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'adopt'
- name: Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
.gradle
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-agentik-
- name: Build :standalone shadowJar
shell: bash
run: |
./gradlew :standalone:shadowJar \
-Pdisable-javadoc=true \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
- name: Build :agentik-cli shadowJar
shell: bash
run: |
./gradlew :agentik-cli:shadowJar \
-Pdisable-javadoc=true \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
- name: Build :agentik-tui shadowJar
shell: bash
run: |
./gradlew :agentik-tui:shadowJar \
-Pdisable-javadoc=true \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
- name: Upload fatjars as release assets
uses: actions/upload-artifact@v4
with:
name: agentik-fatjars
path: |
standalone/build/libs/standalone-all.jar
agentik-cli/build/libs/agentik-cli-all.jar
agentik-tui/build/libs/agentik-tui-all.jar
if-no-files-found: error
retention-days: 90
- name: Attach to release
uses: https://git.binom.pw/actions/forgejo-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
url: ${{ github.server_url }}
repo: ${{ github.repository }}
token: ${{ secrets.GITEA_TOKEN }}
tag: ${{ github.ref_name }}
files: |
standalone/build/libs/standalone-all.jar
agentik-cli/build/libs/agentik-cli-all.jar
agentik-tui/build/libs/agentik-tui-all.jar
+127 -60
View File
@@ -1,86 +1,153 @@
# agentik # agentik
Self-contained multi-module Kotlin Multiplatform агент с долговременной памятью, Локальный stateful LLM-агент с persistent-памятью, инструментами и
персоной, навыками и HTTP-фасадом под `/agentik`. Состоит из библиотечных модулей несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
(KMP, опубликованных в Nexus `caffeine`) и трёх запускаемых артефактов. Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
Qwen) через ONNX/Native-runtime.
## Запускаемые модули ## Что внутри
| Модуль | Что делает | Артефакт | Таргеты | ```
|---|---|---|---| agentik/
| [`:standalone`](standalone/README.md) | HTTP-сервер со всеми транспортами (AG-UI / A2A / `:proto`), SQLite, памятью, скилами, SOUL, MCP | `standalone-all.jar` (≈250 MB) | JVM | ├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
| [`:agentik-cli`](agentik-cli/README.md) | REPL-клиент к `/agentik` со slash-командами | `agentik-cli-all.jar` (≈8 MB) | JVM | ├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
| [`:agentik-tui`](agentik-tui/README.md) | Compose-style TUI-клиент (Mosaic) к `/agentik` | `agentik-tui-all.jar` (≈10 MB) | JVM + macosX64/macosArm64/linuxX64/linuxArm64/mingwX64 | ├── client/ Ktor-клиент → тот же /agentik, с KMP-native
├── skills/ парсер SKILL.md / *.yaml (YAML frontmatter + markdown)
├── memory-api/ контракт долговременной памяти (MemoryStore, MemoryCategory)
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
├── storage-inmemory/ in-memory реализация для тестов и Android
├── storage-sqlite/ SQLite реализация для production
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
```
## Библиотеки Каждый подмодуль имеет собственный `README.md` с деталями
(см. "Модули" ниже).
Все библиотеки — **KMP (jvm + 8 native)**, опубликованы в Nexus-репо `caffeine` ## Quickstart
под группой `pw.binom.agentik`.
### Протокол и транспорт ### 1. Скачать fatjar
- [`:proto`](proto/README.md) — `Agent` / `Conversation` / `Message` / `Event`, типы без сетевой логики. **Stateful** — клиент шлёт только новый message, агент владеет историей.
- [`:server`](server/README.md) — Ktor-фасад, экспонирующий `:proto.Agent` под `/agentik` (HTTP+JSON+SSE).
- [`:client`](client/README.md) — Ktor-клиент, превращающий HTTP `/agentik` обратно в `Agent`/`Conversation`.
### Память CI артефакты доступны на Gitea через GitHub Actions artifacts на
- [`:memory-api`](memory-api/README.md) — контракт: `MemoryStore`, `MemoryNote`, `MemoryPrefetcher`, `MemoryReviewer`, `MemoryTools`. tag-релизах, либо соберите из исходников:
- [`:memory-md`](memory-md/README.md) — Hermes-style реализация поверх §-файлов (`user.md`/`world.md`/`preference.md`).
- [`:memory-vector`](memory-vector/README.md) — JVector (ANN) + SQLite + эмбеддинги (HTTP/SIGLIP on-device).
### Хранилище ```bash
- [`:storage-core`](storage-core/README.md) — `ConversationStore` / `MessageStore` / `WorkingMemoryStore` / `ReflectionStore`. git clone https://git.binom.pw/subochev/agentik
- [`:storage-inmemory`](storage-inmemory/README.md) — in-memory реализация (для тестов и embedded). cd agentik
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLDelight реализация (прод-бэкенд). ./gradlew :standalone:shadowJar
```
### Логика Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
- [`:skills`](skills/README.md) — opencode-style `SKILL.md` / `*.yaml` парсер + рендер в system prompt. зависит от LLM-backend'а).
- [`:agent-toolsets`](agent-toolsets/README.md) — реестр тулов + `enable_toolset`/`disable_toolset` диспетчер.
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
```bash
AGENTIK_LLM_BACKEND=openai \
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
```
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
```bash
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
```
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
## Подключиться
```bash
# CLI
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
# curl
curl http://localhost:8080/health
```
## Модули
- Запускаемые:
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli), JVM + 4 native.
- Библиотеки (контракты и реализации):
- [`:proto`](proto/README.md) — stateful KMP-протокол.
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
- [`:client`](client/README.md) — Ktor-клиент `:server`.
- [`:skills`](skills/README.md) — парсер SKILL.md.
- [`:memory-api`](memory-api/README.md) — контракт памяти.
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
- [`:storage-core`](storage-core/README.md) — контракт storage.
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
## Где смотреть версии ## Где смотреть версии
- `gradle.properties` → `version=0.1.0` (текущая разрабатываемая) Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
- Релизы: `https://git.binom.pw/subochev/agentik/releases` SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
- Опубликованные артефакты: Nexus-репозиторий `caffeine` в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
(`http://nexus.xx/repository/caffeine/pw/binom/agentik/`)
При подключении библиотек используйте одну и ту же `version` (`VERSION` в Gradle Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
зависимостях). Все артефакты синхронизированы и совместимы по ABI в пределах `gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
одной версии. На tag-релизе (например `v0.2.0`) — CI подставляет версию из
тега и публикует.
## Публикация (CI/CD) ## Публикация
`.gitea/workflows/release.yml` — публикует все KMP-таргеты всех модулей в `./gradlew :<module>:publish` → в `caffeine` (Nexus).
Nexus-репо `caffeine` при создании Gitea Release. Версия артефактов берётся из Параметры через:
имени тега (`git tag v0.1.0` → `pw.binom.agentik:*:0.1.0`).
```bash - `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
# Создать релиз: - `binom.repo.user`
git tag v0.1.0 && git push --tags - `binom.repo.password`
# → Gitea → Releases → New Release → выбрать тег → Publish
# → CI публикует в Nexus (нужны секреты BINOM_REPO_USER/BINOM_REPO_PASSWORD)
```
## Сборка …или через переменные `BINOM_REPO_URL`, `BINOM_REPO_USER`,
`BINOM_REPO_PASSWORD` (читаются в release workflow из secret'ов
репозитория). Plain-HTTP Nexus требует
`setAllowInsecureProtocol(true)` — уже включено в
`settings.gradle.kts`.
```bash ## CI/CD
# Всё
./gradlew build
# Только JVM-тесты всех модулей Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
./gradlew jvmTest
# Только fatjar запускаемых модулей - `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
./gradlew :standalone:shadowJar :agentik-cli:shadowJar :agentik-tui:shadowJar shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
# Опубликовать локально в mavenLocal (~/.m2) ## Что отличает от других агентских фреймворков
./gradlew publishToMavenLocal
# Опубликовать в Nexus (нужны креды) - **Stateful protocol** — сервер сам владеет диалогом; переписка не
./gradlew publish -Pversion=0.2.0 \ пересобирается клиентом на каждый `send` (в отличие от AG-UI).
-Pbinom.repo.url=http://nexus.xx/repository/caffeine/ \ - **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
-Pbinom.repo.user=USER -Pbinom.repo.password=PASS Один fatjar — три API.
``` - **Полностью Kotlin Multiplatform** — все контракты компилируются
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
Desktop / CLI.
- **Прерывание tool-calls сохраняется в working memory** — нет
потери контекста, если пользователь нажал Ctrl-C во время
долгого tool-вызова.
## Лицензия ## Лицензия
Apache-2.0 — см. [LICENSE](LICENSE) (если есть). Apache-2.0 — смотрите [LICENSE](LICENSE).
## Участие в проекте
PR-ы приветствуются. Не забывайте синхронизировать версии в
`gradle/libs.versions.toml` и обновлять per-module README при
изменении API.
+64 -50
View File
@@ -1,73 +1,87 @@
# :agent-toolsets — `pw.binom.agentik.toolsets` # `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
**Ядро механики toolsets: `ToolsetRegistry`, `ToolsetDispatchPolicy`, ## Что это
встроенные тулы `enable_toolset` / `disable_toolset`, `SyncLiteTool` базовый
класс.**
KMP, не зависит от `:standalone`, переиспользуем в Android и в любом другом
LiteTool-агенте.
## Какую проблему решает Ядро системы tools для LLM-агента:
В проде у агента может быть **сотня** инструментов (MCP-серверы, кастомные - `Toolset` — интерфейс, объединяющий несколько связанных tools
тулы, встроенные операции). Слать их все в каждый LLM-запрос: (`MemoryTools`, `SkillsTools`, `FileSystemTools`).
- `ToolRegistry` — глобальный реестр + фильтр enabled/disabled.
- `ToolDispatcher` — берёт решение LLM (вызов инструмента с аргументами)
→ запускает → возвращает результат.
- **Cooperative cancel** — `interrupt()` корректно отменяет in-flight
вызов, помечая результат `[cancelled by user]`.
- **Concurrency budget** — `backgroundScope = Dispatchers.IO
.limitedParallelism(4)` (см. коммит `86eb063`) — защищает
threadpool от переполнения при fan-out 30+ диалогов.
1. **Раздувает контекст** — описание тула ~50–200 токенов × 100 тулов = 20K токенов Решает: надёжный механизм tool-calls с прерываниями, без
в system prompt без пользы. blocking-pool exhaustion, без утечки. Переиспользуется во всех
2. **Увеличивает latency** — модель тратит время на выбор из длинного списка. IM-фронтендах (CLI, TUI, IRC, web).
3. **Снижает качество** — модель путается между похожими названиями.
Toolsets группируют тулы **по домену** (`filesystem`, `network`, `devops`, …). ## Где используется
Активированы только 2-3 одновременно. `enable_toolset("filesystem")` —
включает целую группу одним обращением; тулы появляются в system prompt +
регистрируются как вызываемые. `disable_toolset(...)` — убирает.
## Архитектура - `:standalone` подключает несколько `Toolset`-имплементаций
(memory / skills / files / web), фильтрует через
`AGENTIK_TOOLSETS_DEFAULT` env.
## Как подключить
```kotlin ```kotlin
interface Toolset { commonMain.dependencies {
val name: String // "filesystem" api("pw.binom.agentik:agent-toolsets:0.1.0")
val title: String // "File operations"
val enabled: Boolean // текущее состояние
suspend fun enabledTools(context: ToolsetContext): List<LiteTool>
suspend fun systemPromptSection(context: ToolsetContext): String
} }
class ToolsetRegistry { class MyToolset : Toolset {
fun register(toolset: Toolset) override val name = "my"
fun list(): List<Toolset> override val description = "Custom user-defined tools"
suspend fun enable(name: String): Boolean override val tools = listOf(myTool1, myTool2)
suspend fun disable(name: String): Boolean
} }
class ToolsetDispatchPolicy { val dispatcher = ToolDispatcher(
fun buildDispatch(): DispatchPolicy // подаётся в LiteLlm toolsets = listOf(MemoryTools(memory), MyToolset()),
} enabled = setOf("memory", "my"),
)
``` ```
Встроенные тулы — `EnableToolsetTool` / `DisableToolsetTool` / ## Версии
`SystemPromptToolsetSection` — дают LLM самой управлять составом инструментов.
База для кастомных тулов — `SyncLiteTool` (обёртка над `LiteTool`,
синхронная `execute(args): String`).
## Подключение `gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
## Как пишется tool
```kotlin ```kotlin
commonMain { data object EchoTool : Tool {
implementation("pw.binom.agentik:agent-toolsets:$version") override val name = "echo"
// Транзитивно: :storage-core (для ToolsetContext) + :proto override val description = "Echoes back the argument"
override val argsSchema = jsonSchema {
property("text", JsonType.STRING) { required = true }
}
override suspend fun invoke(args: JsonObject): ToolResult {
val text = args["text"]?.jsonPrimitive?.content ?: return ToolResult.Error("missing text")
return ToolResult.Text(text)
}
} }
``` ```
## Где смотреть версии ## Тесты
- `version` из `gradle.properties` (`version=0.1.0`) ```
- релизы: `https://git.binom.pw/subochev/agentik/releases` ./gradlew :agent-toolsets:allTests
## Сборка
```bash
./gradlew :agent-toolsets:build
``` ```
KMP-таргеты — полный набор. Зависимости — `:storage-core` + `:proto` + Покрывают: invoke happy-path, invalid args, cooperative cancel,
`kotlinx-coroutines` + `kotlinx-serialization`. budget exhaustion, registry filter, parallel dispatch.
## Чего здесь НЕТ
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
(см. `:storage-core`).
## Текущий статус
Используется продакшеном. Реализует полную спецификацию из
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
log, rolling buffer, partial-state persistence.
+153 -52
View File
@@ -1,76 +1,177 @@
# :agentik-cli — JVM CLI клиент к /agentik # `:agentik-cli` — one-shot CLI клиент к `/agentik`
REPL-клиент к запущенному `:standalone`-серверу на базе `:client`. **JVM-only** ## Что это
(JLine требует termios + `java.io.File`); для desktop-альтернативы — `:agentik-tui`.
## Сборка **One-shot subcommand CLI** (Kotlin Multiplatform) к серверу
`:standalone` через `:client` по HTTP+SSE. Один вызов — одна команда:
стрим ответа `send` идёт в stdout построчно, никакого embedded-REPL.
Решает: быстрый способ дёрнуть агента из shell-скрипта или руками,
не поднимая отдельную TUI-сессии.
## Платформы
| Платформа | Артефакт | Размер | Статус |
|---|---|---|---|
| `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)
```bash ```bash
./gradlew :agentik-cli:shadowJar ./gradlew :agentik-cli:shadowJar
# Результат: agentik-cli/build/libs/agentik-cli-all.jar (~8 MB) java --enable-native-access=ALL-UNNAMED \
-jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar conv --help
``` ```
Также доступен через Maven Central Nexus (`caffeine` репо) — см. релизы: ### Native linuxX64
`https://git.binom.pw/subochev/agentik/releases`. После публикации нового
тега jar появляется как `pw.binom.agentik:agentik-cli:VERSION` (artifact + classifier `all`).
## Запуск
```bash ```bash
# По умолчанию — http://localhost:8080/agentik ./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
java -jar agentik-cli-all.jar ./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
# К другому серверу
java -jar agentik-cli-all.jar --server http://192.168.76.166:8080/agentik
# Без восстановления последней беседы
java -jar agentik-cli-all.jar --no-history
# В конкретной беседе
java -jar agentik-cli-all.jar --id <conversation-uuid>
``` ```
Альтернативно: `AGENTIK_SERVER` env-переменная с тем же эффектом, что и `--server`. ### Native macOS / Windows
## Slash-команды внутри REPL На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
кросс-компиляцию.
| Команда | Алиасы | Действие | CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
|---|---|---| :agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
| `/help` | `/?` | список команд + клавиш | раннере Gitea Actions.
| `/new <title?>` | `/n` | создать новый диалог |
| `/list` | `/ls` | показать все диалоги |
| `/switch <id>` | `/sw <id>` | переключиться на диалог |
| `/rename <title>` | `/mv <title>` | переименовать текущий |
| `/delete <id?>` | `/rm <id?>` | удалить (без id — текущий) |
| `/interrupt` | `/stop`, `/cancel` | прервать активный ход |
| `/history` | `/h`, `/hist` | backfill истории через `getMessages` |
| `/pwd` | `/where` | показать текущий conversation id |
| `/exit` | `/quit` | выйти (Ctrl-D / Ctrl-C — то же) |
Свободный текст = сообщение текущему диалогу; SSE-события (start_reasoning / ## Примеры
start_response / append_text / end / interrupted / error) стримятся в stdout
в реальном времени.
## Переменные окружения ```bash
# Список диалогов (таблица)
agentik-cli conv ls --server http://localhost:8080/agentik
| Переменная | Дефолт | Назначение | # Создать диалог
|---|---|---| ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
| `AGENTIK_SERVER` | `http://localhost:8080/agentik` | URL HTTP-фасада `:server` | echo "new conv: $ID"
История сессий (id + last event timestamp) сохраняется в # Переименовать
`~/.agentik/cli-state.json` (атомарно через `tmp → rename`). agentik-cli conv rename --server http://localhost:8080/agentik "$ID" "мой чат"
## Особенности # Отправить ход и стримить ответ
agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
- **Arrow keys, history (↑/↓), Ctrl-D/Ctrl-C** — через JLine 3.30, история # Показать последние N сообщений
readline в `~/.agentik/.inputrc`-стиле (через JLine `DefaultHistory`). agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
- **Reconnect-safe SSE** — если сервер рестартовал, клиент подхватывает с
`lastEventAt` через `events(after)`. # Прервать активный ход
- **Snapshot-режим** — если подключились к диалогу впервые, `/history` agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
подгружает старые сообщения через `getMessages(after)` (offline-бэкфилл).
# Удалить
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
покрывается руками против живого сервера.
```bash ```bash
./gradlew :agentik-cli:jvmTest ./gradlew :agentik-cli:jvmTest # 0/0 — пока пусто
``` ```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
+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")
}
} }
} }
} }
// --- Fatjar (uberjar) --- // entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
// // (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`. // функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register. // mainClass.set(...) выше.
listOf(
linuxX64(),
macosX64(),
macosArm64(),
mingwX64(),
).forEach {
it.binaries.executable {
entryPoint = "pw.binom.agentik.cli.main"
}
}
}
// Fatjar — аналог :standalone.
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(),
private val scope = CoroutineScope(Dispatchers.Default) SendSubcommand(),
InterruptSubcommand(),
suspend fun run() { InfoSubcommand(),
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( parser.parse(args)
"прошлый диалог ${shorten(saved.conversationId)} больше не существует",
)
}
} }
printBanner() /**
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
// Главный цикл. * чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
while (scope.isActive) { * свойства родителя в subcommand).
terminal.print(prompt()) */
val line = terminal.readLine() ?: break // EOF → выходим abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
val trimmed = line.trim() val serverUrl: String by option(
if (trimmed.isEmpty()) continue ArgType.String, fullName = "server", shortName = "s",
description = "Base URL агента (env AGENTIK_SERVER)",
if (trimmed.startsWith("/")) { ).default(defaultServerUrl())
when (val r = parseSlash(trimmed.substring(1))) { val agentId: String by option(
is ParseResult.Success -> { ArgType.String, fullName = "id", shortName = "i",
if (handleCommand(r.command) == CommandResult.Exit) break description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
} ).default(defaultAgentId())
is ParseResult.Failure -> terminal.printSystem(r.message)
}
} else {
handleUserMessage(trimmed)
}
}
} finally {
terminal.printSystem("до свидания.")
currentConv?.close()
terminal.close()
sessionRepo.close()
scope.cancel()
}
} }
// ============================================================ banner / prompt fun main(args: Array<String>) {
runAgentikCli(args)
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 }
@@ -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,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,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,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()
+79 -82
View File
@@ -1,106 +1,103 @@
# :agentik-tui — Compose-for-Mosaic TUI клиент к /agentik # `:agentik-tui` — Compose-for-Mosaic TUI-клиент к `/agentik`
Compose-style TUI-клиент (KMP desktop, без iOS), рендерится в ANSI-терминал ## Что это
через библиотеку [Mosaic](https://github.com/JakeWharton/mosaic) 0.18. Полная
клавиатурная навигация — никаких `:`-префиксов (как в vim).
## Сборка Compose-style TUI-клиент в терминале на базе
[Mosaic](https://github.com/JakeWharton/mosaic) (Jetpack Compose
runtime, рендерится в ANSI-коды). Без `:`-команд (без vim-style
prompt): клавиатурная навигация Tab/Enter/Esc/Ctrl-D/F1/стрелки +
жирный focus indicator.
- **Layout**: header (id/conv/focus) + history + input + footer.
- **Focus**: Tab/Shift-Tab цикл по фокусам (input → history → sidebar).
- **Input**: стандартное текстовое поле с курсором `|` посередине.
- **Stream**: подписка на SSE в фон-корутинах, `StateFlow` + `collectAsState()`
для UI-реактивности (см. Snake sample).
Решает: полноценный TUI-клиент для тех, кто предпочитает мышкой
кликать в терминале больше, чем печатать. В отличие от `:agentik-cli`,
показывает историю диалога и текущий стрим в одном окне.
## Как запустить
### Требования
- JVM 21+.
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
- Реальный TTY (через `ssh -tt`, `tmux`, либо нативный terminal).
### Запуск из готового fatjar
```bash ```bash
./gradlew :agentik-tui:shadowJar java --enable-native-access=ALL-UNNAMED \
# Результат: agentik-tui/build/libs/agentik-tui-all.jar (~10 MB) -jar agentik-tui-0.1.0-all.jar \
--server http://192.168.76.166:8080/agentik
``` ```
Также доступен через Nexus (`caffeine` репо) — `pw.binom.agentik:agentik-tui:VERSION`. `--enable-native-access=ALL-UNNAMED` обязателен — Mosaic использует
native syscalls для терминала.
## Запуск ### Запуск через Gradle (dev)
```bash ```bash
# По умолчанию — http://localhost:8080/agentik ./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
java --enable-native-access=ALL-UNNAMED \
-jar agentik-tui-all.jar
# К другому серверу
java --enable-native-access=ALL-UNNAMED \
-jar agentik-tui-all.jar --server http://192.168.76.166:8080/agentik
# Без восстановления последней беседы
java --enable-native-access=ALL-UNNAMED \
-jar agentik-tui-all.jar --no-history
# Конкретная беседа
java --enable-native-access=ALL-UNNAMED \
-jar agentik-tui-all.jar --id <conversation-uuid>
``` ```
Альтернативно: `AGENTIK_SERVER` env-переменная. ## Параметры CLI
> **`--enable-native-access=ALL-UNNAMED`** обязателен: Mosaic использует | Флаг | ENV | Что делает |
> нативные API терминала (`stdin`/`stdout` raw mode), что требует
> `--enable-native-access`.
## Клавиши
| Клавиша | Действие |
|---|---|
| **Tab** / **Shift-Tab** | переключение фокуса: input → history → sidebar → … |
| **Enter** | отправить набранное сообщение |
| **Backspace** / **Delete** | удалить символ |
| **←/→** / **Home/End** | курсор в input |
| **↑/↓** | scrollback (в фокусе на history) / input history (в фокусе на input) |
| **Esc** | очистить input |
| **F1** | показать/скрыть help overlay |
| **Ctrl-D** / **Ctrl-C** | прервать активный ход (повторное нажатие — выход) |
`:`-префикс команд **отключён намеренно** — для отправки команд используются
клавиши. Если нужно сделать что-то нестандартное — переключитесь на `:agentik-cli`
(REPL с slash-командами).
## Переменные окружения
| Переменная | Дефолт | Назначение |
|---|---|---| |---|---|---|
| `AGENTIK_SERVER` | `http://localhost:8080/agentik` | URL HTTP-фасада `:server` | | `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
| `--help` | — | Показывает help и выходит |
История сессий: `~/.agentik/tui-state.json`. ## Keybindings
## Layout | Клавиша | Когда | Что делает |
|---|---|---|
| `Tab` / `Shift-Tab` | глобально | Цикл фокусов: input → history → sidebar → ... |
| `F1` | глобально | Toggle help overlay |
| `Esc` | в input | Очистить input |
| `Enter` | в input | Submit message |
| `Backspace` / `Del` | в input | Удалить символ |
| `←` `→` `Home` `End` | в input | Курсор |
| `↑` `↓` | в history | Scrollback |
| `Ctrl-D` / `Ctrl-C` | — | Exit (TODO — пока работает только вне стрима) |
``` ## Переменные среды (сервера)
┌────────────────────────────────────────────────────────────────┐
│ agentik · agent-id · <conv-uuid> · focus=input │ ← header
├────────────────────────────────────────────────────────────────┤
│ │
│ [User 14:32] │
│ привет │
│ │
│ [Bot 14:32] │
│ привет! чем помочь? │ ← history (scroll)
│ ▍ │
│ │
├────────────────────────────────────────────────────────────────┤
│ > | | │ ← input (cursor)
├────────────────────────────────────────────────────────────────┤
│ Tab focus ↑↓ scroll Enter send Esc clear F1 help ⌃D exit │ ← footer
└────────────────────────────────────────────────────────────────┘
```
## Что пока работает и что нет См. [`../standalone/README.md`](../standalone/README.md). TUI
получает URL сервера через `--server`, остальное настройка
агента, а не клиента.
✅ header + history + input + footer ## Известное ограничение
✅ focus cycling (Tab / Shift-Tab)
✅ input editing (chars, BS, Del, ←/→, Home/End, Enter)
✅ история input'а через ↑/↓ (в фокусе на input)
✅ scrollback history (в фокусе на history)
✅ F1 help overlay
✅ Ctrl-D / Ctrl-C interrupt
⌛ mouse support (термиос SGR-mouse parsing) — для v3+. 1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
⌛ split-pane (history left / input right) — пока input внизу, full-width. же, что для `:agentik-cli`.
⌛ `:agentik-cli`-slash-команды внутри TUI (history backfill / list / switch). 2. **Mouse events не подключены** в v2 (Mosaic 0.18 не имеет
built-in mouse-runtime). Планируется в v3 через termios
SGR-mouse.
3. **Нативные target'ы (macOS / Linux x64+ARM64 / Windows x64)**
собраны, но без `:client` (он JVM-only). Для нативной работы
нужен альтернативный HTTP-клиент.
## Тесты ## Тесты
```bash ```
./gradlew :agentik-tui:jvmTest ./gradlew :agentik-tui:jvmTest
``` ```
Тесты composable'ов и event-рендеринга. Включает smoke-test для
key-event → AppState mutation → ре-рендер.
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agentik-tui`.
## Архитектурная заметка
UI-стейт держится в `StateFlow`, а **не** в Compose `mutableStateOf`.
Причина: Mosaic 0.18 не триггерит recomposition от `mutableStateOf`
-writes внутри `onPreviewKeyEvent`-handler'ов (см. Snake sample в
репо Mosaic — они тоже используют `StateFlow` + `collectAsState()`).
+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)")
+6 -4
View File
@@ -11,14 +11,16 @@ group = "pw.binom.agentik"
// прочитать через rootProject.extra["projectVersion"]. // прочитать через rootProject.extra["projectVersion"].
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с // Publication version: -Pversion=<tag> (CICD publishes by release tag) с
// fallback в gradle.properties ("version=0.1.0"). Без версии maven-publish падает // fallback в gradle.properties (ключ `agentik.version.default`, не `version`
// с "Invalid publication 'kotlinMultiplatform': version cannot be empty" — // — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
// gradle.properties). Без версии maven-publish падает с
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
// это известный gotcha: subprojects читают rootProject.version ДО того, как // это известный gotcha: subprojects читают rootProject.version ДО того, как
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется // if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
// eagerly, и subprojects получают готовую строку. // eagerly, и subprojects получают готовую строку.
val projectVersion: String = providers.gradleProperty("version") val projectVersion: String = providers.gradleProperty("version")
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag .map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
.getOrElse("0.1.0") .getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
version = projectVersion version = projectVersion
extra["projectVersion"] = projectVersion extra["projectVersion"] = projectVersion
@@ -49,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)
+75 -41
View File
@@ -1,67 +1,101 @@
# :client — `pw.binom.agentik.client` # `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
**Ktor-клиент, превращающий HTTP-фасад `:server` обратно в `Agent`/`Conversation` из `:proto`.** ## Что это
Подходит для JVM-приложений (CLI, desktop, integration-тесты).
## Какую проблему решает Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
интерфейсы `:proto`:
После того, как `:server` выставляет агента по HTTP, встаёт задача: дать - `AgentikAgent(id, baseUrl)` — entry-point фабрики.
вызывающей стороне **тот же интерфейс**, что был на сервере — а не отдельный - `AgentClient` — список и lifecycle диалогов.
REST-клиент с хендшейкингом SSE, парсингом полей, ручной постраничной подгрузкой. - `ConversationClient` — `send()`, `events()`, `interrupt()`,
`:client` — обёртка: `AgentikAgent(baseUrl).createConversation()` возвращает `getMessages()`, `rename()`, `close()`.
`Conversation`, идентичный серверному, а вызовы `send/getMessages/events` - Внутренний парсер SSE → `Flow<Event>`.
прозрачно ездят по HTTP.
## Использование Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
с теми же типами, что и сервер. Один и тот же клиент работает на
JVM, iOS, macOS, Linux, Windows.
## Где используется
- `:agentik-cli` — REPL.
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
## Как подключить
```kotlin ```kotlin
import pw.binom.agentik.client.AgentikAgent // build.gradle.kts
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:client:0.1.0")
}
}
val agent = AgentikAgent(id = "ops-bot", baseUrl = "http://localhost:8080/agentik") // ваш код:
val conv = agent.createConversation(temp = false) val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
conv.events(after = Clock.System.now()).collect { ev -> val conv = agent.createConversation(title = "test")
when (ev) { conv.send(listOf(Content.Text("hello"))).collect { event ->
is Event.AppendText -> print(ev.body) when (event) {
is Event.End -> println() is Event.AppendText -> print(event.body)
is Event.End -> println("\n--- end ---")
is Event.Error -> error("agent error: ${event.message}")
else -> Unit else -> Unit
} }
} }
conv.send(listOf(Content.Text("Привет. Сколько будет 2+2?")))
// ... события стримятся в collect выше
conv.close()
``` ```
Для фоновой подписки (reconnect-safe): ## Версии
`gradle/libs.versions.toml` → `[versions] agentik-client`.
Поддерживает все KMP-таргеты, что и `:proto`.
## Примеры API
```kotlin ```kotlin
// Долгая живая подписка на события диалога. // список диалогов
conv.events(after = lastSeen).collect { ev -> agent.getConversations().collect { println(it.id to it.title) }
if (ev is Event.End) lastSeen = ev.date
// live-подписка на события отдельного диалога
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
// прерывание текущего хода
conversation.interrupt()
// история
conversation.getMessages(offset = 0).collect { msg ->
when (msg) {
is Message.UserMessage -> println("user: ${msg.content}")
is Message.AssistantMessage -> println("assistant: ${msg.content}")
else -> Unit
}
} }
``` ```
## Подключение ## Тесты
```kotlin ```
implementation("pw.binom.agentik:client:$version") ./gradlew :client:jvmTest
// Транзитивно тянет :proto + ktor-client-core/cio/... + kotlinx-serialization.
``` ```
## SSE-парсер Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
401/404.
Внутри — самописный парсер SSE (режет поток на `data:` строки, буферизует ## Чего здесь НЕТ
частичные, переживает keep-alive-комментарии). Зависимости — `ktor-client-cio`
по умолчанию; если нужен другой engine — подмените через `AgentikAgent(engineFactory = …)`.
## Где смотреть версии - Никакого LLM-кода. Это просто клиент.
- Никакого persistent state. История хранится у сервера, клиент её
запрашивает через `getMessages` или подписывается через `events`.
- `:client` синхронизирован с `:proto`/`server` — `version` из `gradle.properties` ## Текущий статус
- релизы: `https://git.binom.pw/subochev/agentik/releases`
## Сборка Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
но клиент совместим с любым сервером, который держит wire-контракт
`:server`.
```bash ## Известное ограничение
./gradlew :client:build
```
JVM-only (ktor-client-engine-cio — JVM). SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE.
+34 -9
View File
@@ -1,18 +1,26 @@
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)
}
}
dependencies { // Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
implementation(project(":proto")) // :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.core)
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
@@ -23,3 +31,20 @@ dependencies {
implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json) 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-бинари объявляются
// в :agentik-cli (он зависит от :client и реально предоставляет main).
}
@@ -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,7 +67,8 @@ 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() }
.execute { response ->
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}" "events: server returned ${response.status}"
} }
@@ -76,3 +78,4 @@ internal class AgentClient(
} }
} }
} }
}
@@ -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,7 +73,11 @@ 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` дожидается полного
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) { check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}" "events: server returned ${response.status}"
} }
@@ -81,6 +86,7 @@ internal class ConversationClient(
emit(agentikJson.decodeFromString(Event.serializer(), payload)) emit(agentikJson.decodeFromString(Event.serializer(), payload))
} }
} }
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
httpClient.get("$convUrl/messages") { httpClient.get("$convUrl/messages") {
@@ -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)
}
}
}
+6 -2
View File
@@ -1,5 +1,9 @@
# Default version for local builds; overridden by `-Pversion=<tag>` from CI/CD. # Default version for local builds only (когда CI/CD не передал -Pversion=<tag>).
version=0.1.0 # Имя ключа специально НЕ 'version' — иначе Gradle-мерж gradle.properties и
# -Pversion= возьмёт default из gradle.properties. Передавай через CICD:
# ./gradlew ... -Pversion=$(git describe --tags)
# см. .gitea/workflows/release.yml (использует -Pversion=$GITHUB_REF_NAME).
agentik.version.default=0.1.0-SNAPSHOT
# KMP jvm target uses JDK 21 for both compilation and toolchain. # KMP jvm target uses JDK 21 for both compilation and toolchain.
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
+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
+56 -53
View File
@@ -1,77 +1,80 @@
# :memory-api — `pw.binom.agentik.memory` # `:memory-api` — контракт долговременной памяти (KMP, jvm + native)
**Интерфейсы долговременной памяти агента: `MemoryStore`, `MemoryNote`, ## Что это
`MemoryCategory`, `MemoryPrefetcher`, `MemoryReviewer`, `MemoryTools`.**
Без зависимостей от конкретного хранилища.
## Какую проблему решает Интерфейсы долговременной памяти агента:
LLM не помнит между сессиями. Чтобы агент становился **умнее с каждым - `MemoryStore` — append-only журнал `MemoryNote(id, content, createdAt)`.
диалогом**, нужна долговременная память: факты о пользователе, мире, - `MemoryCategory` — discriminator (`USER`, `WORLD`, `PREFERENCE`,
предпочтениях, плюс механизм извлечения (reviewer) и подмешивания (prefetcher) кастомные).
в контекст. Бэкенды памяти бывают разные (md-файлы, векторный ANN, sqlite, - `MemoryNote` — структурная единица памяти; immutable.
KV-store), и `:standalone` не должен быть привязан ни к одному из них. - Прелоадер / ревьювер по контракту, не по реализации.
`:memory-api` определяет **контракт**: что умеет любая реализация памяти. Решает: как единая абстракция позволяет иметь одновременно файловую
Конкретные бэкенды — `:memory-md` (Hermes-style §-файлы) и `:memory-vector` память (`:memory-md`), SQLite + ANN (`:memory-vector`) и тестовую
(SQLite + JVector + эмбеддинги). in-memory (в `:standalone/tests`). Агент работает с `MemoryStore`,
не с конкретным бэкендом.
## Что в контракте ## Где используется
- `:memory-md` — Hermes-style `§`-файлы (user.md / world.md /
preference.md).
- `:memory-vector` — SQLite + JVector + LLM-эмбеддинги.
- `:standalone` подключает обе реализации и переключает через
`AGENTIK_MEMORY_BACKEND=md|vector|off`.
## Как подключить
```kotlin ```kotlin
interface MemoryStore : AutoCloseable { kotlin {
suspend fun upsert(note: MemoryNote) sourceSets.commonMain.dependencies {
suspend fun get(id: String): MemoryNote? api("pw.binom.agentik:memory-api:0.1.0")
suspend fun list(category: MemoryCategory?, conversationId: String?, limit, offset): List<MemoryNote> }
suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> }
suspend fun delete(id: String): Boolean ```
suspend fun markUsed(ids: List<String>) // бампит lastUsedAt + useCount
fun events(): Flow<MemoryStoreEvent> // опционально Артефакт публикуется в `caffeine`.
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-memory-api`.
## Что в API
```kotlin
interface MemoryStore {
suspend fun save(category: MemoryCategory, content: String): MemoryNote
suspend fun query(category: MemoryCategory?, q: String, limit: Int = 10): List<MemoryNote>
suspend fun all(category: MemoryCategory? = null): List<MemoryNote>
} }
enum class MemoryCategory { USER, WORLD, PREFERENCE } enum class MemoryCategory(val path: String) {
USER("user"),
WORLD("world"),
PREFERENCE("preference");
}
data class MemoryNote( data class MemoryNote(
val id: String, val id: String,
val conversationId: String?,
val category: MemoryCategory, val category: MemoryCategory,
val title: String, val content: String,
val body: String,
val source: MemorySource, // AUTO_REVIEW / USER / MANUAL
val createdAt: Instant, val createdAt: Instant,
val lastUsedAt: Instant?,
val useCount: Int,
) )
``` ```
`MemoryPrefetcher` — компонент, который **перед** каждым user-ходом выбирает ## Тесты
релевантные заметки (через `search()`) и форматирует `[Memory context…]` блок
в начало user-сообщения. `MemoryReviewer` — компонент, который **после**
assistant-хода извлекает новые факты (через LiteLlm) и вызывает `upsert`.
`MemoryTools` — `memory_save` / `memory_recall` / `memory_list` / `memory_delete`,
которыми модель может пользоваться явно.
## Подключение ```
./gradlew :memory-api:allTests
```kotlin
commonMain {
implementation("pw.binom.agentik:memory-api:$version")
// + выберите реализацию:
implementation("pw.binom.agentik:memory-md:$version") // md-файлы
// или
implementation("pw.binom.agentik:memory-vector:$version") // JVector+SQLite
}
``` ```
## Где смотреть версии Контрактные тесты на Kotlin Multiplatform (без jvmTest-специфики).
- `version` из `gradle.properties` (`version=0.1.0`) ## Чего здесь НЕТ
- релизы: `https://git.binom.pw/subochev/agentik/releases`
## Сборка - Никаких конкретных storage — это API. Backend-ы в `:memory-md` и
`:memory-vector`.
```bash ## Текущий статус
./gradlew :memory-api:build
```
KMP-таргеты — полный набор. Зависимостей нет (только `kotlinx-coroutines-core` для `Flow`). Используется продакшеном. Контракт стабильный.
+58 -44
View File
@@ -1,63 +1,77 @@
# :memory-md — `pw.binom.agentik.memory.md` # `:memory-md` — файловое хранилище памяти (JVM-only)
**Hermes-style реализация долговременной памяти поверх обычных markdown-файлов.** ## Что это
По одной §-секции на заметку, в файлах `user.md` / `world.md` / `preference.md`.
Без внешних зависимостей, без эмбеддингов, без БД.
## Какую проблему решает Реализация `MemoryStore` поверх обычных файлов в формате [Hermes-style]:
Минимально работающая память **без инфраструктуры**: открыл текстовый редактор — - `~/.agentik/memory/user.md`
посмотрел, поправил, удалил. Версионируется в git вместе с проектом, бэкапится - `~/.agentik/memory/world.md`
как обычные файлы. Полезно для дев-окружения, для одиночных пользователей, - `~/.agentik/memory/preference.md`
для отладки vector-бэкенда.
## Формат файла Каждая секция — это `## <heading>` + содержимое. Ревьювер ищет
по заголовкам/словам по ключевому совпадению. Префетчер лениво
подгружает секции, наиболее вероятно относящиеся к текущему ходу.
Решает: простой, прозрачный, git-дружелюбный формат памяти.
Пользователь может сам `cat ~/.agentik/memory/world.md` и
отредактировать.
## Где используется
- `:standalone` подключает вместо `:memory-vector` когда
`AGENTIK_MEMORY_BACKEND=md`.
- Дефолт, когда ANN-эмбеддинги слишком дороги или не нужны.
## Как подключить
```kotlin
dependencies {
implementation("pw.binom.agentik:memory-md:0.1.0")
implementation("pw.binom.agentik:memory-api:0.1.0") // контракт
}
val memory: MemoryStore = openMdMemorySystem(Path("~/.agentik/memory"))
memory.save(MemoryCategory.USER, "User prefers tasks short.")
memory.query(MemoryCategory.USER, "preferences").forEach(::println)
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-memory-md`.
## Как устроен формат
```markdown ```markdown
# user.md # user.md
# agentik-note id=a3f1e2b7 created=2026-09-01 uses=3 last=2026-09-12 ## 2026-09-14T10:00:00Z — first session
Пользователь предпочитает короткие ответы без эмодзи. Не любит вим. Имя пользователя — Сережа.
Любит короткие ответы.
# agentik-note id=b9c4d8e1 created=2026-09-10 uses=1 last=2026-09-12 ## 2026-09-15T18:20:00Z — task preferences
Дедлайн релиза agentik — 25 сентября. Не присылать пустые репро.
# preference.md
# agentik-note id=c1d2e3f4 created=2026-09-08 uses=0 last=never
Отвечать по-русски, без жаргона.
``` ```
Заголовок с метаданными (`# agentik-note id=… created=… uses=… last=…`), пустая Каждая запись начинается с заголовка второго уровня и содержит в
строка, markdown-body. Curator (фоновая корутина) переименовывает «протухшие» первой строке заголовка timestamp и короткое название. Так достигается
заметки (`useCount=0` и `last > 90 дней назад`) в `.archived.{ts}`. уникальность и читаемость через `cat`.
## Подключение ## Тесты
```kotlin ```
commonMain { ./gradlew :memory-md:jvmTest
implementation("pw.binom.agentik:memory-md:$version")
// транзитивно: :memory-api + kaml (для YAML-сериализации метаданных)
}
// Использование
val store = MemoryMdStore.fromDirectory(
dir = Path("~/.agentik/memory"),
clock = Clock.System,
)
val search = store.search(MemorySearchQuery(query = "любимый редактор", limit = 5))
``` ```
## Где смотреть версии Покрывают: round-trip save/load, фильтрацию по категории,
keyword-search, перезапись, конкурентный доступ (файловая блокировка).
- `version` из `gradle.properties` (`version=0.1.0`) ## Чего здесь НЕТ
- релизы: `https://git.binom.pw/subochev/agentik/releases`
## Сборка - Никаких эмбеддингов. Простой keyword-match (простая substring +
TF-IDF-эвристика на русских/латинских словах).
- Никакого ANN. Для семантического поиска используйте `:memory-vector`.
```bash ## Текущий статус
./gradlew :memory-md:build
```
KMP-таргеты — полный набор. Зависимости — `:memory-api` + `kaml` (YAML-парсинг Используется продакшеном. Подходит для долговременного "дневникового"
метаданных). хранения.
+57 -51
View File
@@ -1,72 +1,78 @@
# :memory-vector — `pw.binom.agentik.memory.vector` # `:memory-vector` — ANN/JVector/SQLite память с эмбеддингами (JVM-only)
**Реализация долговременной памяти поверх JVector (ANN-индекс) + SQLite (метаданные) + эмбеддингов.** ## Что это
JVM-only (JVector не публикует KMP-таргеты; на Android ART работает через Java 11
scalar fallback).
## Какую проблему решает Реализация `MemoryStore` поверх SQLite + [JVector](https://github.com/jbellis/jvector)
+ LLM-эмбеддинги:
`:memory-md` хорош для малых объёмов и дев-окружения, но при тысячах заметок - **Хранение метаданных** — SQLite (notes, timestamps, источник).
keyword-overlap поиск не справляется. Vector-бэкенд считает **эмбеддинги** - **ANN-индекс** — JVector (тот же класс HNSW, что используется в
заметок, складывает в JVector ANN-индекс, ищет по cosine similarity. Cassandra DataStax).
`recency-re-rank` подмешивает свежесть, чтобы новые факты не тонули в старых. - **Эмбеддинги** — два backendа:
- **HTTP** — POST на любой OpenAI-совместимый `/v1/embeddings`
(vLLM, LiteLLM, text-embedding-ada-002, и т.д.).
- **SigLIP2** — локальная модель через [text-embedding-kmp](https://git.binom.pw/subochev/text-embedding-kmp)
(ONNX Runtime, без сети).
## Архитектура Решает: семантический поиск по памяти. "Где я рассказывал про
CI/CD" находит нужный эпизод, даже если формулировка другая. При
этом offline-capable через SigLIP.
``` ## Где используется
┌───────────────────────┐
user-query ─►│ EmbeddingProvider │ (HTTP /v1/embeddings или SIGLIP2 on-device)
└─────────┬─────────────┘
▼
┌───────────────────────┐
│ MemoryVectorStore │
│ ├─ JVector (cosine) │ ◄── ANN-search
│ └─ SQLite (мета) │ ◄── заметки + lastUsedAt + useCount
└───────────────────────┘
```
Бэкенды эмбеддингов (через `AGENTIK_EMBEDDING_BACKEND`): - `:standalone` подключает как `AGENTIK_MEMORY_BACKEND=vector`
(с `AGENTIK_EMBEDDING_BACKEND=http|siglip`).
- **`HTTP`** — POST на `${OPENAI_BASE_URL}/v1/embeddings`. Семантический поиск ## Как подключить
через OpenAI-совместимый endpoint (vLLM, OpenAI, LiteLLM-proxy).
Кэширование LRU(256) на уровне `EmbeddingHttpProvider` для дедупликации.
- **`SIGLIP`** — on-device SigLIP2 через ONNX Runtime (768-мерный вектор).
Никаких внешних вызовов; модель и токенизатор должны лежать на диске.
## Подключение
```kotlin ```kotlin
plugins { kotlin("jvm") }
dependencies { dependencies {
implementation("pw.binom.agentik:memory-vector:$version") implementation("pw.binom.agentik:memory-vector:0.1.0")
// Транзитивно: :memory-api + jvector + sqldelight + text-embedding-kmp implementation("pw.binom.agentik:memory-api:0.1.0")
} }
val memory = VectorMemorySystem.open(
dbPath = Path("~/.agentik/mem.db"),
embedding = HttpEmbeddingClient(
apiUrl = "http://192.168.88.135:8001/v1",
apiKey = "no-key-needed",
model = "text-embedding-3-small",
dimension = 1536,
),
)
``` ```
`:standalone` инициализирует бэкенд автоматически по `AGENTIK_MEMORY_BACKEND=vector` ## Версии
+ `AGENTIK_EMBEDDING_BACKEND=…`.
## Размерности `gradle/libs.versions.toml` → `[versions] agentik-memory-vector`.
| Backend | Модель | Dim | **Зависит от** `pw.binom.ai.embeddingtext:api-jvm:3.0.0-SNAPSHOT`
|---|---|---| и `pw.binom.ai.embeddingtext:siglip-jvm:3.0.0-SNAPSHOT` из репо
| `HTTP` (OpenAI) | `text-embedding-3-small` (default) | 1536 | `caffeine` (см. `../gradle/libs.versions.toml`). Оба опубликованы
| `HTTP` (OpenAI) | `text-embedding-3-large` | 3072 | вручную (`Binom-PIN-Caffeine`).
| `SIGLIP` | SigLIP2-base | 768 (фиксировано) |
Для `HTTP` размерность управляется через `AGENTIK_EMBEDDING_DIMENSION`; для ## Как работает embedding-флоу
`SIGLIP` — определяется автоматически.
## Где смотреть версии 1. `memory.save(cat, "text")` — text → embedding (HTTP или SigLIP)
→ row в SQLite + вектор в JVector-индекс.
2. `memory.query(cat, "q")` — q → embedding → ANN top-K (default K=10)
→ скоры, deduplication, реплес с timestamp.
- `version` из `gradle.properties` (`version=0.1.0`) ## Тесты
- релизы: `https://git.binom.pw/subochev/agentik/releases`
## Сборка ```
./gradlew :memory-vector:jvmTest
```bash
./gradlew :memory-vector:build
``` ```
JVM-only. Тянет `pw.binom.agentik:memory-api` и `com.github.jvector:jvector:3.0.6`. Покрывают: round-trip, ANN top-K, SigLIP (если модель скачана),
SQLite-migration. SigLIP-тест skipped без модели на диске.
## Чего здесь НЕТ
- Никакого HTTP-клиента к LLM для генерации ответов. Это только
embedding-клиент. Сам LLM-вызов — в `:standalone`.
## Текущий статус
Используется продакшеном. Подходит для крупных памятей (10000+
заметок) и семантических запросов.
+15 -17
View File
@@ -2,6 +2,16 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
} }
// CI-флаг: при -PskipVectorMemory=true зависимости text-embedding-kmp
// не подключаются. Нужно для CI runner'а — text-embedding-kmp ещё не
// опубликован в caffeine, артефакты есть только в локальном ~/.m2.
// Использование:
// ./gradlew :memory-vector:compileKotlinJvm -PskipVectorMemory=true
// Локальная разработка без флага — зависимости подключаются как обычно.
val skipVectorMemory: Boolean =
(project.findProperty("skipVectorMemory") == "true") ||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -24,26 +34,14 @@ kotlin {
jvmMain.dependencies { jvmMain.dependencies {
implementation(libs.jvector) implementation(libs.jvector)
implementation(libs.sqldelight.sqlite.driver) implementation(libs.sqldelight.sqlite.driver)
// text-embedding-kmp — on-device SigLIP2 через ONNX Runtime.
// Сигнатура `embed(String): TextEmbedding` (blocking), оборачиваем
// наш `suspend fun embed(text)` через Mutex. api-вариант экспортируем
// (`api`), потому что SiglipEmbeddingProvider реализует `embed()`
// через тип TextEmbeddingExtractor, который виден потребителю
// только если он сам подтянет api-jvm — проще пробросить.
//
// WORKAROUND: upstream `siglip-jvm/*.module` ссылается на `api`
// БЕЗ -jvm суффикса. Поскольку в mavenLocal есть только `api-jvm`,
// требуется дополнительный stub-jar `pw.binom.ai.embeddingtext:api`
// с тем же содержимым. Создаётся так:
// mkdir -p ~/.m2/repository/pw.binom.ai.embeddingtext/api/3.0.0-SNAPSHOT
// cp ~/.m2/repository/.../api-jvm/3.0.0-SNAPSHOT/api-jvm-*.{jar,sources.jar} \
// ~/.m2/repository/.../api/3.0.0-SNAPSHOT/api-*.{jar,sources.jar}
// Когда upstream починит module-metadata — эту инструкцию можно убрать.
api(libs.text.embedding.api)
implementation(libs.text.embedding.siglip)
} }
jvmTest.dependencies { jvmTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
} }
} }
} }
dependencies {
add("jvmMainApi", libs.text.embedding.api)
add("jvmMainImplementation", libs.text.embedding.siglip)
}
+106 -67
View File
@@ -1,94 +1,133 @@
# :proto — `pw.binom.agentik.proto` # `:proto` — протокол общения с агентом (KMP, jvm + native)
**Stateful KMP-протокол взаимодействия клиента с агентом.** ## Что это
Замена AG-UI в проектах, где агенту нужно **самому владеть** историей диалога и
контекстным окном (компакция, рефлексия, выбор инструментов) — клиент только
стримит сообщения и рисует события.
## Какую проблему решает Типы и контракт in-house протокола `agentik`, заменившего AG-UI:
AG-UI требует, чтобы **клиент** слал полный `messages[]` на каждый ход, а агент - **stateful** — сервер сам владеет диалогом; клиент шлёт только новые
оставался stateless. Это удобно для UI-чатов, но ломается, когда: сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан
повторять `messages[]` каждый раз).
- **declarative история vs. события** — `Message` это то, что уже легло
в БД, `Event` это live-стрим от агента во время `send()` или `events()`.
- **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри
`:proto`; это контракт.
- у агента есть долговременная память (md/vector), и контекст должен Решает проблему: AG-UI клиент вынужден каждый раз знать и пересобирать
автоматически сжиматься / дополняться перед отправкой в LLM; полную историю, а его серверная часть (`AbstractAgent`) — постоянно
- у одного пользователя десятки активных диалогов и нельзя каждый раз сериализовать-десериализовать всю переписку. В `:proto` сервер один,
пересылать 100K токенов; контракт тонкий, переписка персистится нативно (SQLite, файлы, что
- агент сам планирует вызовы инструментов и управляет KV-cache модели. хотите). Можно подменить front-end или back-end, протокол остаётся.
`:proto` переворачивает ответственность: **агент** владеет `Conversation.messages()`, ## Где используется
`send(content)` отправляет только новый message, а `events(after): Flow<Event>`
стримит live-события с `Instant`-таймстампом для отслеживания прогресса.
## Контракт - `:server` — Ktor-фасад, маппит `Agent` ↔ HTTP/SSE.
- `:client` — Ktor-клиент, маппит HTTP/SSE ↔ `Agent/Conversation`.
- `:agentik-cli` — работает поверх `:client`, а следовательно поверх `:proto`.
*(`:agentik-tui` был исключён из сборки 2026-09-17.)*
- `:standalone` — реализует `Agent` (через `ChatAgent`) и пишет/читает
`Message`/`Event` напрямую через storage.
## Как подключить
```kotlin
// build.gradle.kts
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:proto:0.1.0")
}
}
```
Артефакт `pw.binom.agentik:proto:0.1.0` живёт в Nexus-репозитории
`caffeine` (HTTP `http://<your-nexus>/repository/caffeine/`, plain-HTTP,
credentials — через переменные `binom.repo.user/password/url`).
## Версии
Каталог `gradle/libs.versions.toml`, секция `[versions]` → `agentik-proto`.
Поднять версию → переопубликовать все KMP-таргеты через `./gradlew
:proto:publish -Pversion=...` (или триггернуть Gitea release).
Текущие KMP-таргеты: `jvm + macosX64/macosArm64 +
iosX64/iosArm64/iosSimulatorArm64 + linuxX64/linuxArm64 + mingwX64`.
## Публикация
Настройки в `gradle.properties` / env: `binom.repo.url`, `binom.repo.user`,
`binom.repo.password`. `./gradlew :proto:publish` публикует все
target-specific артефакты + общий `kotlinMultiplatform`.
## Основные типы
```kotlin ```kotlin
interface Agent { interface Agent {
val id: String fun id: String
suspend fun createConversation(temp: Boolean = false): Conversation suspend fun createConversation(title: String? = null): Conversation
suspend fun getConversation(id: String): Conversation? suspend fun getConversation(id: String): Conversation?
suspend fun getConversations(offset: Int = 0, limit: Int = PAGE_SIZE): List<Conversation> suspend fun getConversations(offset: Int = 0): Flow<Conversation>
fun getConversations(offset: Int = 0): Flow<Conversation> // cold-flow по страницам suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
fun events(after: Instant): Flow<AgentEvent> // live-уведомления о диалогах
} }
interface Conversation : AutoCloseable { interface Conversation : AutoCloseable {
val id: String val id: String
val title: String?
val updatedAt: Instant val updatedAt: Instant
val isSupportImageInput: Boolean val isSupportImageInput: Boolean
val isSupportImageOutput: Boolean val isSupportImageOutput: Boolean
suspend fun send(content: List<Content>): Unit // write-only, не блокирует suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
fun events(after: Instant): Flow<Event> // live read (НЕ replay!) suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
suspend fun getMessages(offset: Int, limit: Int = PAGE_SIZE): List<Message> suspend fun getMessages(offset: Int = 0): Flow<Message>
fun getMessages(offset: Int = 0): Flow<Message> // cold-flow по страницам
suspend fun rename(title: String): Boolean suspend fun rename(title: String): Boolean
suspend fun interrupt(): Unit fun interrupt()
fun close() // освобождает ресурсы }
sealed interface Content {
class Text(val body: String) : Content
class Image(val data: ByteArray, val mime: String) : Content
}
sealed interface Message {
val id: String
val date: Instant
interface Body : Message { val content: List<Content> }
interface System : Message
class UserMessage(...) : Body
class AssistantMessage(...) : Body
class ToolCall(...) : System
class ToolResult(...) : System
}
sealed interface Event {
enum ResponseType { TEXT, IMAGE }
class StartReasoning(...) : Event
class StartResponse(val type: ResponseType) : Event
class AppendText(val body: String) : Event
class AppendImage(val body: ByteArray, val mime: String) : Event
class End(...) : Event
class Interrupted(...) : Event
class Error(val message: String, val code: Int? = null) : Event
class ToolCall(...) : Event
class ToolResult(...) : Event
} }
``` ```
Иерархии: ## Чего здесь НЕТ
- `Content` — `Text` / `Image(data: ByteArray, mime: String)` - Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
- `Message` — `UserMessage` / `AssistantMessage` (оба `Body`) + `ToolCall(id, name, args)` / `ToolResult(id, result)` / `System` для метаданных и `:client`.
- `Event` — `StartReasoning` / `StartResponse(type)` / `AppendText` / `AppendImage` / `End` / `Interrupted` / `Error` - Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
- `AgentEvent` — `Created(id)` / `Deleted(id)` / `Renamed(id, title)` - Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
внутри `:standalone` (ChatAgent) и выше.
Live-стримы (`events(after)`) **не реплеят** прошедшие события — клиент должен ## Тесты
сам вызвать `getMessages(after)` для бэкфилла, либо подписаться на `events(after=now)`
и начать рисовать с настоящего момента.
## Подключение ```
./gradlew :proto:jvmTest
Артефакт — `pw.binom.agentik:proto:VERSION`. ./gradlew :proto:allTests # дополнительно linuxX64 (если Linux) / iosSimulator (если macOS)
```kotlin
// commonMain
implementation("pw.binom.agentik:proto:$version")
// JVM-only
implementation("pw.binom.agentik:proto-jvm:$version")
// Любой KMP-таргет
implementation("pw.binom.agentik:proto-macosArm64:$version")
``` ```
`version` синхронизируется с `gradle.properties` (`version=0.1.0`) и ## Текущий статус
прокидывается через `-Pversion=...` в CI (`binom.repo.*` для Nexus).
## Где смотреть версии Используется продакшеном. Иммутабельный API (после рефакторинга из
AG-UI). Возможные будущие расширения: typed tool-result, multi-modal
- текущая разрабатываемая: `gradle.properties` → `version=0.1.0` contents, server-pushed references — все обсуждаются через общий
- история релизов: `https://git.binom.pw/subochev/agentik/releases` [IRC-QUESTIONS.md](../IRC-QUESTIONS.md).
- опубликованные артефакты: Nexus-репозиторий `caffeine`
(`http://nexus.xx/repository/caffeine/pw/binom/agentik/proto/`)
## Сборка / тесты
```bash
./gradlew :proto:build # все KMP-таргеты + тесты
./gradlew :proto:jvmTest # только JVM-тесты
./gradlew :proto:publishToMavenLocal # для локального потребления
```
KMP-таргеты: `jvm + macosX64 + macosArm64 + iosX64 + iosArm64 + iosSimulatorArm64 + linuxX64 + linuxArm64 + mingwX64`.
Зависимостей минимум: `kotlinx-coroutines-core:1.11.0` + `kotlinx-datetime:0.8.0` (оба `api`).
+66 -44
View File
@@ -1,66 +1,88 @@
# :server — `pw.binom.agentik.server` # `:server` — HTTP/SSE фасад для `:proto` (KMP, JVM-only)
**Ktor-маршруты, превращающие `pw.binom.agentik.proto.Agent` в HTTP+JSON+SSE фасад.** ## Что это
Подключается к любому `Application` через `Route.agentikAgent(...)`, монтируется
под заданным `path` (по умолчанию `/agentik`).
## Какую проблему решает Ktor-маршрут, экспонирующий `Agent` из `:proto` в виде JSON-API:
`POST /agentik/conversations`, `POST /agentik/conversations/:id/send`,
`GET /agentik/conversations/:id/events` (SSE), `GET /health`,
`GET /agentik/conversations`.
`:proto` — это чистый Kotlin-контракт (Agent/Conversation). Чтобы по нему - **stateful** — сервер не принимает полную историю, только новые
говорить с удалённым сервером нужен мост. `:server` — этот мост, но **только сообщения. История хранится там, где развёрнут `Agent`.
маршруты**: без привязки к конкретному engine (CIO/Netty), без агрегации - **декларативно** — `interface Agent` → HTTP; никакой магии, никаких
прочих протоколов (AG-UI/A2A), без инициализации БД/памяти. Один extension-метод обёрток. Контракт и сериализация — тоже декларативные (kotlinx-json
на `Route`, и ваш Agent доступен по HTTP. с snake_case-дискриминаторами).
## Что отдаёт Решает: позволяет собрать любой собственный front-end (CLI/TUI/Web/
IRC/MCP) общаясь с одним сервером по стабильному wire-контракту.
| Метод | Путь | Назначение | ## Где используется
|---|---|---|
| `GET` | `/health` (через `:standalone`) | liveness |
| `POST` | `/agentik/conversations` | создать диалог |
| `GET` | `/agentik/conversations` | список диалогов (постраничный) |
| `GET` | `/agentik/conversations/{id}` | конкретный диалог |
| `POST` | `/agentik/conversations/{id}/rename` | переименовать |
| `DELETE` | `/agentik/conversations/{id}` | удалить |
| `POST` | `/agentik/conversations/{id}/messages` | отправить сообщение |
| `GET` | `/agentik/conversations/{id}/events` | SSE-стрим событий |
| `GET` | `/agentik/conversations/{id}/messages` | backfill истории (с `after`) |
Wire-форма — `kotlinx.serialization` JSON со `snake_case`-дискриминаторами - `:standalone` подключает `Route.agentikAgent(agent)` в свой
(`"kind":"start_response"`, `"kind":"user_message"`, и т.п.). `Instant` embedded Netty engine.
сериализуется ISO-8601 строкой (`agentikJson` в `Serialization.kt`). - Любые клиенты (наши `:client`, `:agentik-cli`, или
внешние web-фронтенды) идут через этот контракт.
## Подключение ## Как подключить
```kotlin ```kotlin
// your-application/build.gradle.kts // build.gradle.kts (KMP JVM target)
implementation("pw.binom.agentik:server:$version") plugins { id("pw.binom.agentik.server-conventions") version "0.1.0" }
implementation("io.ktor:ktor-server-core:2.3.x") // или любой совместимый dependencies {
implementation("io.ktor:ktor-server-cio:2.3.x") // engine — ваш выбор api("pw.binom.agentik:server:0.1.0")
api("pw.binom.agentik:proto:0.1.0")
}
// ваш код // ваш код:
import pw.binom.agentik.server.agentikAgent fun Application.module(agent: Agent) {
fun Application.module() {
install(ContentNegotiation) { json(agentikJson) } install(ContentNegotiation) { json(agentikJson) }
install(SSE)
routing { routing {
agentikAgent(agent = myAgent) // mount на /agentik route("/agentik") { agentikAgent(agent) }
agentikAgent(agent = myAgent, path = "/v1/agent") // или под другим путём
} }
} }
``` ```
## Где смотреть версии ## Версии
- `pw.binom.agentik:proto` (см. `:proto/README.md`) `gradle/libs.versions.toml` → `[versions] agentik-server`.
- `pw.binom.agentik:server` — `version` берётся из `gradle.properties` (`version=0.1.0`)
- история релизов: `https://git.binom.pw/subochev/agentik/releases`
## Сборка ## Эндпоинты (path по умолчанию `/agentik`, через `agentikAgent(agent, "/my")`)
```bash | Метод | Путь | Что делает |
./gradlew :server:build |---|---|---|
| `POST` | `/conversations` | Создать диалог (body: `{title?}`) |
| `GET` | `/conversations` | Список диалогов (по `?offset=&limit=`) |
| `GET` | `/conversations/:id` | Снимок диалога + count |
| `GET` | `/conversations/:id/messages` | История сообщений (по `?after=`) |
| `POST` | `/conversations/:id/rename` | Переименовать (body: `{title}`) |
| `DELETE` | `/conversations/:id` | Удалить |
| `POST` | `/conversations/:id/send` | Send-флоу (body: `{content:[…]}` → SSE) |
| `GET` | `/conversations/:id/events` | Live подписка (SSE) |
| `POST` | `/conversations/:id/interrupt` | Прервать текущий `send()` |
`Content-Type: text/event-stream` всегда для SSE, ноль-лишних
заголовков. Сообщения: `event: <name>` (`message`, `start_reasoning`,
`start_response`, `append_text`, `append_image`, `end`, `interrupted`,
`error`) + `data: <JSON>`.
## Тесты
```
./gradlew :server:jvmTest ./gradlew :server:jvmTest
``` ```
KMP-таргеты: те же, что у `:proto` (jvm + 8 нативов). Покрывают: маппинг JSON ↔ Event, SSE framing, error-handling,
404 / 400 ответы, корректную обработку `Instant` в `kotlinx-datetime`.
## Чего здесь НЕТ
- Никакого LLM-кода, tool-вызовов, прерываний. Только mapping Agent ↔ HTTP.
- Никакой БД, никакого storage. Это задача `Agent`-имплементации.
- Никакого CORS-конфига по умолчанию — добавляйте на свой engine.
## Текущий статус
Используется продакшеном. Wire-контракт стабильный; новые Event'ы
добавляются только с snake_case-дискриминаторами и строго обратно
совместимо.
+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")
+65 -63
View File
@@ -1,77 +1,79 @@
# :skills — `pw.binom.agentik.skills` # `:skills` — парсер SKILL.md (KMP, JVM-only)
**Парсер opencode-style скилов: `SKILL.md` или `*.yaml` с YAML-frontmatter + markdown body.** ## Что это
Загружается в system prompt как отдельная секция; модели доступны тулы
`read_skill` / `skill_save` / `skill_delete` (если они подключены через
`:agent-toolsets`).
## Какую проблему решает Парсер и runtime для навыков агента в формате [opencode Skills](https://docs.opencode.dev):
Агенту нужно **знать**, какие процедуры/инструкции у него есть, не таская их - **SKILL.md / \*.yaml** с YAML-frontmatter (`name`, `description`,
в коде. Скил — это: `allowed-tools`, etc.) и markdown-телом.
- Реестр `SkillCatalog`, лоадер `SkillLoader` (поиск по
`~/.agentik/skills/`).
- `Skill` имеет стабильный id, описание, может требовать определённые
tools (`allowed-tools: [run_command, write_file]`) — это контролируется
на уровне вызова.
- Загруженные скиллы аггрегируются в system-prompt через
`Skill.toSystemPromptSection()` или подгружаются по требованию через
tool `read_skill`.
Решает задачу: агенту нужно объяснить "что я умею" на разных языках
(нативный skill-вызов vs. описание), нужно уметь включать/выключать
навыки по требованию, и нужно хранить текстовые навыки прямо в
git-репозитории (а не в БД).
## Где используется
- `:standalone` подгружает все SKILL.md из `~/.agentik/skills/`
и инструментового скилл-майнера (skill-mining: создание новых
SKILL.md по LLM-рефлексии).
- Активно юзается для: `code-review`, `arch-summary`, `telegram-reply`,
любых "habits" агента.
## Как подключить
```kotlin
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:skills:0.1.0")
}
}
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-skills`.
## Пример SKILL.md
```markdown ```markdown
--- ---
name: backend:spring:db-base name: code-review
description: Правила JPA/Flyway миграций и репозиториев в agentik. description: Review uncommitted diff and produce line-anchored comments.
allowed-tools: [run_command, read_file]
--- ---
Правила, которые применяются ко всем изменениям схемы:
- миграции только в Flyway; You are a strict reviewer. For every change in the diff, output:
- новые колонки nullable by default; - File: <path>
- ... - Severity: <nit|warning|blocker>
- Comment: <one sentence>
Only mention issues that are objectively wrong. Do not refactor.
``` ```
Парсер читает такие файлы из каталога (рекурсивно), валидирует обязательные ## Тесты
поля (`name`, `description`), собирает каталог для system prompt и **поддерживает
горячее обновление**: добавил файл — доступен в следующем `read_skill` без
рестарта.
## Формат
Поддерживаются оба варианта:
- **`SKILL.md`** — единый файл в каталоге (с frontmatter + body).
- **`*.yaml`** + опциональный `*.md`-компаньон с тем же basename.
Frontmatter — YAML, минимум `name` (с `:` для вложенности) и `description`.
Остальные поля — пользовательские, доступны через `SkillFile.frontmatter`.
## Подключение
```kotlin
commonMain {
implementation("pw.binom.agentik:skills:$version")
implementation("com.charleskorn.kaml:kaml:0.55.0")
}
``` ```
## Использование
```kotlin
import pw.binom.agentik.skills.SkillCatalog
val catalog = SkillCatalog.fromDirectory(Path("/etc/agentik/skills"))
catalog.listAll().forEach { skill ->
println("- ${skill.name}: ${skill.description}")
}
val skill = catalog.findByName("backend:spring:db-base")
val body = skill?.body
```
`SkillPrompt` умеет отрендерить каталог в markdown-секцию для system prompt
(с truncate по длине, чтобы не раздувать контекст).
## Где смотреть версии
- `version` из `gradle.properties` (`version=0.1.0`)
- релизы: `https://git.binom.pw/subochev/agentik/releases`
## Сборка
```bash
./gradlew :skills:build
./gradlew :skills:jvmTest ./gradlew :skills:jvmTest
``` ```
KMP-таргеты — полный набор как у `:proto`. Зависимости — только `kotlinx-serialization` + `kaml`. Покрывают: парсинг yaml-frontmatter, обработку отсутствующих полей,
unicode-имена, дубликаты id, очень большое тело.
## Чего здесь НЕТ
- Никакого HTTP / tool-вызова. Парсер и реестр — не более.
- Никакой БД. SKILL.md живут в файлах под управлением пользователя.
## Текущий статус
Используется продакшеном. Парсер простой и предсказуемый; расширять
формат frontmatter можно без поломок (новые поля игнорируются).
+118 -128
View File
@@ -1,154 +1,144 @@
# :standalone — agentik single-jar server # `:standalone` — single-jar HTTP-сервер со всеми транспортами
Self-contained HTTP-сервер на Ktor: AG-UI / A2A / :proto транспорты на одном порту, ## Что это
встроенный SQLite для истории диалогов, долговременная память (Hermes-style
§-файлы или vector+JVector), загрузка MCP-инструментов, навыков (SKILL.md) и
персоны (SOUL.md).
## Сборка Главный исполняемый модуль проекта — single-jar HTTP-сервер с:
- **AG-UI** transport на `POST /agui` (SSE) + `GET /health`.
- **A2A** transport на `POST /` (JSON-RPC) + `GET /.well-known/agent-card.json`.
- **`:proto`** transport на `POST /agentik/*` (HTTP+JSON+SSE) — наш stateful.
- **Embedded LLM backend**: `GOOGLE` (LiteRT) или `OPENAI`-совместимый
(vLLM, LiteLLM, OpenAI API).
- **SQLite persistence** через `:storage-sqlite`.
- **Memory backend**: `md` (файловый) или `vector` (SQLite+JVector+
HTTP/SIGLIP-embeddings).
- **Skills** из `~/.agentik/skills/*.md`.
- **SOUL** из `~/.agentik/SOUL.md`.
- **Background подпроцессы**: рефлексия, skill-mining,
memory-reviewer.
Решает: даёт пользователю один JAR (10–250 МБ), который запускается
через `java -jar agentik-0.1.0-all.jar`, и поднимает сразу все
транспорты, которые другие системы могут хавать.
## Как запустить
### Требования
- JVM 21+.
- (Опционально) CUDA-устройство для `:backend=google` (LiteRT).
- (Опционально) LM через OpenAI-совместимый endpoint (vLLM / Ollama
/ OpenAI) для `:backend=openai`.
### Запуск из готового fatjar
```bash ```bash
# Полная сборка всего проекта + fatjar java --enable-native-access=ALL-UNNAMED \
./gradlew assemble -jar agentik-0.1.0-all.jar
# Только fatjar :standalone (≈ 250 MB)
./gradlew :standalone:shadowJar
# Результат:
# standalone/build/libs/standalone-all.jar
``` ```
Также публикуется в Nexus (`caffeine` репо) при создании релиза: С дефолтами — встроенный SQLite, OpenAI-compatible backend на
`pw.binom.agentik:standalone:VERSION` с classifier `all` (см. `http://localhost:8001/v1`, порт 8080.
`https://git.binom.pw/subochev/agentik/releases`).
## Запуск ### Запуск через Gradle (dev)
```bash ```bash
java -jar standalone/build/libs/standalone-all.jar ./gradlew :standalone:run
``` ```
По умолчанию слушает на `http://localhost:8080`. Healthcheck: `GET /health`. ### `pull-model` subcommand (для LiteRT)
Транспорты на одном порту:
- `GET /health` — liveness
- `POST /agentik/conversations` — создать беседу
- `GET /agentik/conversations/{id}/events` — SSE-стрим ответов
- `POST /a2a/` — A2A JSON-RPC (`message/send`, `tasks/get`, `tasks/cancel`)
- `GET /a2a/.well-known/agent-card.json` — AgentCard
- `POST /agui` — AG-UI (compatibility transport, legacy)
### Подкоманды
```bash ```bash
# Скачать модель Google LiteRT-LM в AGENTIK_GOOGLE_MODEL_PATH. # Сначала скачать модель под LiteRT-Gemma-4-E2B
# Требует AGENTIK_LLM_BACKEND=google. Если файл уже есть — no-op. AGENTIK_LLM_BACKEND=google \
java -jar standalone-all.jar pull-model AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
java --enable-native-access=ALL-UNNAMED \
# Сервер (по умолчанию) -jar agentik-0.1.0-all.jar pull-model
java -jar standalone-all.jar
``` ```
## Переменные окружения Скачивает `https://static.binom.pw/models/gemma-4-E2B-it.litertlm`
(2.5 ГБ, с Range-resume). Поддерживает override через
`AGENTIK_GOOGLE_MODEL_URL` и verify через
`AGENTIK_GOOGLE_MODEL_SHA256_URL`.
Все переменные читаются `AgentikConfig.fromEnv()`. Пусто или отсутствие → дефолт. ## Переменные среды
| Переменная | Дефолт | Назначение | Полный список — общий для всего `:standalone`-процесса:
| Env | Default | Что делает |
|---|---|---| |---|---|---|
| `AGENTIK_PORT` | `8080` | Порт HTTP-сервера | | `AGENTIK_PORT` | `8080` | Порт HTTP-сервера |
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite (история бесед + метаданные памяти) | | `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite |
| `AGENTIK_SKILLS_DIR` | _выкл._ | Каталог со скилами (`SKILL.md` / `*.yaml`) | | `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) |
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory` (md) или `AGENTIK_DB_PATH` (vector) | Каталог памяти (md); `"off"` отключает | | `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` |
| `AGENTIK_MEMORY_BACKEND` | `md` | `md` (Hermes-style §-файлы) / `vector` (SQLite + JVector + LLM-эмбеддинги) / `off` | | `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели |
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Модель эмбеддингов для vector-бэкенда (только HTTP) | | `AGENTIK_LLM_API_URL` | `http://localhost:8001/v1` | Endpoint для OpenAI-compatible |
| `AGENTIK_EMBEDDING_DIMENSION` | `1536` | Размерность вектора (только HTTP; SIGLIP определяет автоматически) | | `AGENTIK_LLM_API_KEY` | `no-key-needed` | Auth header |
| `AGENTIK_EMBEDDING_BACKEND` | `HTTP` | `HTTP` (POST /v1/embeddings) или `SIGLIP` (on-device, без сети) | | `AGENTIK_LLM_CONTEXT_TOKENS` | `115000` | Сколько токенов остаётся модели |
| `AGENTIK_EMBEDDING_MODEL_PATH` | _только SIGLIP_ | Путь к `text_model_int8.onnx` (SigLIP2) | | `AGENTIK_GOOGLE_MODEL_PATH` | `/root/gemma-4-E2B-it.litertlm` | Путь к `.litertlm` файлу |
| `AGENTIK_EMBEDDING_TOKENIZER_PATH` | _только SIGLIP_ | Путь к `tokenizer.model` (sentencepiece) | | `AGENTIK_GOOGLE_MODEL_URL` | `https://static.binom.pw/models/gemma-4-E2B-it.litertlm` | Откуда скачивать |
| `AGENTIK_SOUL` | _выкл._ | Путь к `SOUL.md` — файл персоны (markdown), вставляется в начало system prompt | | `AGENTIK_GOOGLE_MODEL_SHA256_URL` | — | Если задан — verify по SHA-256 |
| `AGENTIK_LLM_BACKEND` | — | `openai` или `google` (см. ниже) | | `AGENTIK_AUTO_DOWNLOAD_MODEL` | `0` | `1` = скачать модель если её нет |
| `AGENTIK_MCP_CONFIG` | _выкл._ | Путь к JSON со списком MCP-серверов | | `AGENTIK_MEMORY_BACKEND` | `vector` | `md`, `vector` или `off` |
| `AGENTIK_SYSTEM_PROMPT` | `be brief` | Базовый system prompt | | `AGENTIK_EMBEDDING_BACKEND` | `http` | `http` или `siglip` (только для `vector`) |
| `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) | | `AGENTIK_EMBEDDING_API_URL` | `http://localhost:8001/v1` | Endpoint для эмбеддингов |
| `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend | | `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Имя embedding-модели |
| `AGENTIK_COMPRESSION_THRESHOLD` | `0.8` | Доля лимита, при которой запускается compaction | | `AGENTIK_SOUL_PATH` | `~/.agentik/SOUL.md` | Путь к SOUL.md |
| `AGENTIK_REFLECTION_INTERVAL` | `10` | Self-reflection: каждый N-й пользовательский ход агент оценивает себя (LiteLlm) и сохраняет рефлексию. `0` = выключено. | | `AGENTIK_SKILLS_DIR` | `~/.agentik/skills/` | Каталог SKILL.md |
| `AGENTIK_REFLECTION_TOP_K` | `3` | Сколько последних рефлексий подмешивать в system prompt как «слабые места». `0` = не подмешивать. | | `AGENTIK_MEMORY_DIR` | `~/.agentik/memory/` | Каталог для md-памяти |
| `AGENTIK_SKILL_MINING_INTERVAL` | `15` | Skill mining: через сколько user-ходов запускать фоновый прогон SkillMiner. `0` = выключено. | | `AGENTIK_TOOLSETS_DEFAULT` | `memory,skills,files,web` | Включённые тулы |
| `AGENTIK_SKILL_MINING_MAX_TURNS` | `30` | Сколько последних ходов передавать SkillMiner'у за один прогон. | | `AGENTIK_DEBUG` | `0` | `1` = verbose logging |
| `AGENTIK_DEBUG_ENDPOINTS` | `0` | `1` включает debug-эндпоинты (`/debug/reflect`, `/debug/skill-mine`, `/debug/curate`, `/debug/compact`, `/debug/tokens`) |
| `AGENTIK_AUTO_DOWNLOAD_MODEL` | `0` | `1` — при старте скачать LiteRT-LM модель в `AGENTIK_GOOGLE_MODEL_PATH` если её там нет (через `pull-model` логику). |
### OpenAI backend Значения читаются через `AgentikConfig.fromEnv()` в `:standalone/.../Main.kt`.
| Переменная | Обязательна | Назначение | ## Эндпоинты
|---|---|---|
| `OPENAI_BASE_URL` | да | Например, `https://api.openai.com/v1` |
| `OPENAI_API_KEY` | да | API key |
| `OPENAI_MODEL` | да | Имя модели (`gpt-4o-mini` и т.п.) |
### Google backend | Метод | Путь | Transport | Описание |
|---|---|---|---|
| Переменная | Обязательна | Назначение | | `GET` | `/health` | любой | health-check (`{"ok":true}`) |
|---|---|---| | `POST` | `/agui` | AG-UI | Стриминг run (SSE) |
| `AGENTIK_GOOGLE_MODEL_PATH` | да | Путь к `.litertlm` файлу | | `POST` | `/` | A2A | JSON-RPC `message/send`, `tasks/get`, `tasks/cancel` |
| `AGENTIK_GOOGLE_MODEL_URL` | нет | URL для скачивания (default: `https://static.binom.pw/models/gemma-4-E2B-it.litertlm`) | | `GET` | `/.well-known/agent-card.json` | A2A | Discovery |
| `AGENTIK_GOOGLE_MODEL_SHA256_URL` | нет | URL с эталонным SHA-256 (если задано — файл проверяется) | | `POST` | `/agentik/conversations` | :proto | Создать диалог |
| `GET` | `/agentik/conversations` | :proto | Список диалогов |
## Полный пример запуска | `GET` | `/agentik/conversations/:id` | :proto | Snapshot |
| `GET` | `/agentik/conversations/:id/messages` | :proto | История |
```bash | `POST` | `/agentik/conversations/:id/send` | :proto | Send (SSE) |
export AGENTIK_PORT=8080 | `GET` | `/agentik/conversations/:id/events` | :proto | Live-events (SSE) |
export AGENTIK_DB_PATH=/var/lib/agentik/state.db | `POST` | `/agentik/conversations/:id/interrupt` | :proto | Прервать |
export AGENTIK_MEMORY_DIR=/var/lib/agentik/memory | `POST` | `/agentik/conversations/:id/rename` | :proto | Переименовать |
export AGENTIK_SOUL=/etc/agentik/SOUL.md | `DELETE` | `/agentik/conversations/:id` | :proto | Удалить |
export AGENTIK_SKILLS_DIR=/etc/agentik/skills
export AGENTIK_MCP_CONFIG=/etc/agentik/mcp.json
export AGENTIK_LLM_BACKEND=openai
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_API_KEY=sk-…
export OPENAI_MODEL=gpt-4o-mini
mkdir -p "$(dirname "$AGENTIK_DB_PATH")" \
"$AGENTIK_MEMORY_DIR" \
"$(dirname "$AGENTIK_SOUL")" \
"$(dirname "$AGENTIK_MCP_CONFIG")"
java -jar standalone/build/libs/standalone-all.jar
```
На старте выведет что-то вроде:
```
agentik standalone listening on http://localhost:8080
GET /health
POST /agentik/conversations -> 201
GET /agentik/conversations/{id}/events -> SSE
storage: /var/lib/agentik/state.db
llm: OPENAI gpt-4o-mini @ https://api.openai.com/v1
mcp: 4 tools from 2 servers
skills: 3 loaded from /etc/agentik/skills
soul: /etc/agentik/SOUL.md (842 chars)
memory: /var/lib/agentik/memory (md-backend)
compaction: enabled, threshold=0.8, window=128000 tokens
curator: enabled (interval=1d, maxAge=90d)
reflection: enabled (interval=10, topK=3)
```
## Остановка
`Ctrl-C` → срабатывает shutdown hook: агент, MCP-серверы, SQLite-стора и
LLM-клиент закрываются корректно (SQLite фиксирует WAL, MCP-процессы
получают SIGTERM).
## Клиенты к :standalone
- `:agentik-cli` — REPL со slash-командами (`/new`, `/list`, `/interrupt` …).
- `:agentik-tui` — Compose-style TUI, чисто клавиатурная навигация.
## Тесты ## Тесты
```bash
./gradlew :standalone:jvmTest
``` ```
./gradlew :standalone:jvmTest # unit-тесты
./gradlew :standalone:integrationTest # integration (Testcontainers)
./gradlew :standalone:shadowJar # → build/libs/agentik-0.1.0-all.jar
```
## Известные ограничения
1. **vLLM не поддерживает cancel-inference** (`interrupt()` только
закрывает client SSE-socket; бэкенд всё равно генерирует до конца).
2. **A2A JSON discriminator — `"kind"`** (text/file/data),
а не `"type"`. См. `A2aJson` в `:standalone`.
3. **SSE в не-TTY ssh закрывается на default Ktor timeout**.
## Текущий статус
Production-ready. Все KMP-модули проекта интегрированы. Полный
manual-test checklist смотрите в [`MANUAL-TESTS.md`](../../MANUAL-TESTS.md)
или `MANUAL-TESTS.md` в корне.
## Где скачать
- **Source**: `git clone https://git.binom.pw/subochev/agentik`
- **Fatjar**: Gitea CI artifacts (через `.gitea/workflows/release.yml`
на tag `v*`) или собирается через `./gradlew :standalone:shadowJar`.
## Версии
Все `gradle/libs.versions.toml`. Поднять версию → release через
`git tag v0.2.0 && git push --tags` → CI собирает все KMP-таргеты
публикует артефакты.
+9
View File
@@ -12,6 +12,13 @@ plugins {
alias(libs.plugins.shadow) alias(libs.plugins.shadow)
} }
// CI-флаг: при -PskipVectorMemory=true :memory-vector не подключается как
// зависимость — нужно для CI runner'а (text-embedding-kmp ещё не опубликован
// в caffeine). Подробнее см. memory-vector/build.gradle.kts.
val skipVectorMemory: Boolean =
(project.findProperty("skipVectorMemory") == "true") ||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
@@ -46,7 +53,9 @@ kotlin {
// Долговременная память (Hermes-style MD-бэкенд) + хранилище истории. // Долговременная память (Hermes-style MD-бэкенд) + хранилище истории.
implementation(project(":memory-api")) implementation(project(":memory-api"))
implementation(project(":memory-md")) implementation(project(":memory-md"))
if (!skipVectorMemory) {
implementation(project(":memory-vector")) implementation(project(":memory-vector"))
}
implementation(project(":storage-core")) implementation(project(":storage-core"))
implementation(project(":storage-sqlite")) implementation(project(":storage-sqlite"))
implementation(project(":agent-toolsets")) implementation(project(":agent-toolsets"))
+56 -56
View File
@@ -1,77 +1,77 @@
# :storage-core — `pw.binom.agentik.storage` # `:storage-core` — контракт хранилища (KMP, jvm + native)
**Интерфейсы хранилища разговорной истории агента: `ConversationStore`, ## Что это
`MessageStore`, `WorkingMemoryStore`, `ReflectionStore`.**
Без зависимостей от конкретной БД.
## Какую проблему решает Интерфейсы persistence-уровня для `:standalone`:
`:standalone` нужно сохранять диалоги между перезапусками, держать working - `MessageStore` — append-only история сообщений по диалогу.
memory (сжатую историю, которую видит LLM), reflection-записи. Но привязываться - `WorkingMemoryStore` — rolling buffer текущего хода (`AssistantMessage`,
к конкретной БД (SQLite) в контракте нельзя — для Android подходит другая `ToolExchange`, `UserMessage`, system-prompt) для fast-recovery
история, для тестов — in-memory, для экспериментов с Postgres — третья. при reconnect/relance.
- `ConversationStore` — метаданные диалогов (id, title, model,
timestamps).
- `ReflectionStore` — LLM-reflections (свободная форма заметок
хранителя).
`:storage-core` отделяет **что хранить** (контракт) от **где хранить** Решает: позволяет запустить агента на Android-in-memory, на
(бэкенды — `:storage-sqlite`, `:storage-inmemory`, и в будущем `:storage-android`). desktop-SQLite, или на production-SQLite, не переписывая логику.
Агрегатор `StorageBundle` собирает все 4 стора разом. Контракт минимален и async-friendly.
## Что в контракте ## Где используется
- `:storage-inmemory` — для тестов и Android.
- `:storage-sqlite` — прод (Desktop / server / однодесктопный
Android-development).
## Как подключить
```kotlin ```kotlin
interface ConversationStore : AutoCloseable { kotlin {
suspend fun upsert(c: Conversation) sourceSets.commonMain.dependencies {
suspend fun get(id: String): Conversation? api("pw.binom.agentik:storage-core:0.1.0")
suspend fun list(offset, limit, orderBy): List<Conversation>
suspend fun delete(id: String): Boolean
fun events(after: Instant): Flow<ConversationStoreEvent>
} }
interface MessageStore : AutoCloseable {
suspend fun append(message: Message, conversationId: String, workingMemoryIndex: Int?)
suspend fun listByConversation(conversationId: String, after: Instant?, offset, limit): List<Message>
suspend fun update(message: Message, conversationId: String) // правка + бамп updatedAt
suspend fun deleteByConversation(conversationId: String)
}
interface WorkingMemoryStore : AutoCloseable {
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, index: Int)
suspend fun listByConversation(conversationId: String, after: Instant?): List<WorkingMemoryEntry>
suspend fun compact(conversationId: String, fromIndex: Int, summary: SummaryEntry)
suspend fun reset(conversationId: String)
}
interface ReflectionStore : AutoCloseable {
suspend fun append(conversationId: String, reflection: ReflectionEntry)
suspend fun recent(conversationId: String?, topK: Int): List<ReflectionEntry>
} }
``` ```
`WorkingMemoryEntry` — `sealed interface`: `User`, `Assistant`, `ToolExchange`, ## Версии
`Summary`. Каждый entry имеет `instant` (когда попал в working memory) и
`index` (порядковый номер для восстановления при компакции).
## Подключение `gradle/libs.versions.toml` → `[versions] agentik-storage-core`.
## Что в API
```kotlin ```kotlin
commonMain { interface MessageStore {
implementation("pw.binom.agentik:storage-core:$version") suspend fun append(conversationId: String, message: Message): Unit
// + бэкенд: suspend fun after(conversationId: String, instant: Instant, limit: Int = 100): List<Message>
implementation("pw.binom.agentik:storage-inmemory:$version") // тесты }
// или
implementation("pw.binom.agentik:storage-sqlite:$version") // прод interface WorkingMemoryStore {
suspend fun save(conv: String, entry: WorkingMemoryEntry): Unit
fun load(conv: String): Flow<WorkingMemoryEntry> // cold flow
suspend fun clear(conv: String): Unit
}
sealed interface WorkingMemoryEntry {
val id: String
val date: Instant
class UserMessage(...) : WorkingMemoryEntry
class AssistantMessage(...) : WorkingMemoryEntry
class ToolExchange(val toolName: String, val toolArgsJson: String, val resultText: String, val wasCancelled: Boolean) : WorkingMemoryEntry
class SystemPrompt(...) : WorkingMemoryEntry
} }
``` ```
## Где смотреть версии ## Тесты
- `version` из `gradle.properties` (`version=0.1.0`) Контрактные тесты (общие для всех имплементаций) — в `:storage-sqlite`
- релизы: `https://git.binom.pw/subochev/agentik/releases` и `:storage-inmemory`.
## Сборка ## Чего здесь НЕТ
```bash - Никакого HTTP / SSE.
./gradlew :storage-core:build - Никакой конкретной БД. Backend'ы в `:storage-*`.
```
KMP-таргеты — полный набор. Зависимости — только `kotlinx-coroutines` + ## Текущий статус
`kotlinx-serialization`.
Используется продакшеном. Контракт зафиксирован после
interrupt-имплементации (см. [INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md)).
@@ -59,7 +59,7 @@ class PayloadTest {
} }
@Test @Test
fun `legacy plain-array payload still decodes (backward compat)`() { fun `legacy plain-array payload still decodes backward compat`() {
val legacy = "[" + val legacy = "[" +
"""{"type":"text","body":"old message"}""" + """{"type":"text","body":"old message"}""" +
"]" "]"
+39 -26
View File
@@ -1,42 +1,55 @@
# :storage-inmemory — `pw.binom.agentik.storage.inmemory` # `:storage-inmemory` — in-memory реализация `:storage-core` (KMP, jvm + native)
**In-memory реализация всех 4 сторов из `:storage-core`.** ## Что это
Без платформенного IO, KMP-таргеты полностью. Тред-безопасна через `Mutex`.
## Какую проблему решает In-memory реализация `MessageStore / WorkingMemoryStore /
ConversationStore / ReflectionStore`. Все структуры держит в
`ConcurrentHashMap` + `MutableList`, фолотится на RAM
(никаких файлов).
- **Тесты** — мгновенный setup/teardown, нет файловой системы, нет JDBC, Решает: дешёвая тестовая среда без поднятия SQLite. Позволяет
детерминированное состояние. прогонять `ChatAgentTest` за миллисекунды и держать сценарии
- **Embedded-сценарии** (Android ART, iOS) где SQLDelight native-драйвер пока детерминированными.
недоступен или избыточен.
- **Отладка** — можно в рантайме дампить `MessageStore.listByConversation()` и
смотреть что попало в working memory без sqlite3 CLI.
Семантика **полностью совпадает** с `:storage-sqlite`: один и тот же контракт ## Где используется
`:storage-core`, одинаковые `AutoCloseable`, одинаковая тред-безопасность.
## Подключение - В тестах `:standalone` (`AbstractITTest`).
- В Android-имплементации (in-memory + Android-database микс).
- В любых юнит-тестах на агенте.
## Как подключить
```kotlin ```kotlin
commonMain { commonMain.dependencies {
implementation("pw.binom.agentik:storage-inmemory:$version") api("pw.binom.agentik:storage-inmemory:0.1.0")
api("pw.binom.agentik:storage-core:0.1.0")
} }
// Использование val storage = InMemoryStorageSystem()
val bundle = InMemoryStorageBundle(clock = Clock.System) val messages: MessageStore = storage.messages
val conv = bundle.conversations.upsert(Conversation(id = "test", title = "demo", createdAt = now)) val working: WorkingMemoryStore = storage.working
bundle.messages.append(message = UserMessage("hi"), conversationId = conv.id, workingMemoryIndex = 0)
``` ```
## Где смотреть версии ## Версии
- `version` из `gradle.properties` (`version=0.1.0`) `gradle/libs.versions.toml` → `[versions] agentik-storage-inmemory`.
- релизы: `https://git.binom.pw/subochev/agentik/releases`
## Сборка ## Тесты
```bash ```
./gradlew :storage-inmemory:build ./gradlew :storage-inmemory:allTests
``` ```
KMP-таргеты — полный набор. Зависимости — только `:storage-core`. Покрывают (через общие contract-tests): round-trip, paged flow,
concurrent appends, working-memory replay, очистку.
## Чего здесь НЕТ
- Никакого persistence. Перезапуск процесса — данные пропали.
Это нормально для тестов и Android in-memory.
## Текущий статус
Используется продакшеном (в режиме тестов). Контракт-совместима
с `:storage-sqlite` 1:1 — переключение `AGENTIK_STORAGE_BACKEND=memory`
в `:standalone`.
@@ -68,7 +68,7 @@ class InMemoryConversationStoreTest {
} }
@Test @Test
fun `rename updates title and updatedAt, returns new updatedAt`() = runTest { fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
val store = InMemoryConversationStore() val store = InMemoryConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z") val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0)) store.upsert(ConversationRecord("c1", null, false, t0, t0))
+59 -43
View File
@@ -1,62 +1,78 @@
# :storage-sqlite — `pw.binom.agentik.storage.sqlite` # `:storage-sqlite` — SQLite реализация `:storage-core` (JVM-only)
**SQLDelight-реализация всех 4 сторов из `:storage-core` поверх SQLite.** ## Что что это
JVM-only (SQLDelight пока не публикует KMP-драйверы за пределами JVM/Android).
## Какую проблему решает Production persistence для `:standalone` на [SQLDelight](https://cashapp.github.io/sqldelight/):
Прод-запуск `:standalone` должен переживать рестарт. In-memory не подходит — - **messages** — append-only журнал с `conversation_id`, `created_at`.
нужна **реальная БД**. SQLite выбран потому что: - **working_memory** — rolling buffer последних 100 entries, типы
в JSON (`UserMessage / AssistantMessage / ToolExchange / SystemPrompt`).
- **conversations** — метаданные (id, title, model, timestamps).
- **reflections** — произвольные заметки ("I notice you often
prefer short replies").
- Промпт хранителя (`@mem0`) индексирован отдельно для быстрого
доступа.
- **Один файл** (`AGENTIK_DB_PATH`) — легко бэкапить, переносить, инспектить. Решает: стабильная, локальная, нулевая-настройка БД. Подходит и для
- **WAL** — запись не блокирует чтение; диалоги не фризят при compaction. desktop-продакшена, и для Android, и для тестов (через Testcontainers).
- **Без отдельного сервиса** — в отличие от PostgreSQL/MySQL не нужно ничего
поднимать рядом.
## Схема ## Где используется
SQLDelight `.sq`-файлы в `src/main/sqldelight/`: - `:standalone` подключает по умолчанию (`storage.db` = путь из
`AGENTIK_DB_PATH`).
- `Conversation.sq` — `conversations` (id, title, created_at, updated_at, is_temporal) ## Как подключить
- `Message.sq` — `messages` (id, conversation_id, role, content, working_memory_index, date)
- `WorkingMemory.sq` — `working_memory` (id, conversation_id, entry_kind, payload_json, index, instant)
- `Reflection.sq` — `reflection` (id, conversation_id, score, weak_points_json, created_at)
`SqliteStores` (фасад) собирает все 4 стора в `StorageBundle` поверх общего
SQLite-driver. Каждый store — отдельный класс, с тред-безопасностью через
`Mutex` на запись.
## Подключение
```kotlin ```kotlin
plugins { jvmMain.dependencies {
kotlin("jvm") implementation("pw.binom.agentik:storage-sqlite:0.1.0")
alias(libs.plugins.sqldelight) implementation("pw.binom.agentik:storage-core:0.1.0")
} }
dependencies { val storage = SqliteStorageSystem.open(Path("agentik.db"))
implementation("pw.binom.agentik:storage-sqlite:$version") val messages: MessageStore = storage.messages
// Транзитивно: :storage-core + sqldelight-runtime/coroutines + sqlite-driver
}
// Использование
val driver = JdbcSqliteDriver("jdbc:sqlite:agentik.db")
SqliteStores.Schema.migrate(driver) // CREATE TABLE IF NOT EXISTS + ALTER
val bundle = SqliteStores.fromDriver(driver, clock = Clock.System)
``` ```
`:standalone` инициализирует бэкенд автоматически по `AGENTIK_DB_PATH`. ## Версии
## Где смотреть версии `gradle/libs.versions.toml` → `[versions] agentik-storage-sqlite`.
- `version` из `gradle.properties` (`version=0.1.0`) Зависит от `app.cash.sqldelight:sqlite-driver:2.1.0` (через
- релизы: `https://git.binom.pw/subochev/agentik/releases` `gradle/libs.versions.toml`).
## Сборка ## Тесты
```bash ```
./gradlew :storage-sqlite:build ./gradlew :storage-sqlite:jvmTest
``` ```
JVM-only. Тянет `:storage-core` + `app.cash.sqldelight:runtime` + Покрывают: миграции (через `migrations/` каталог и SQLDelight
`app.cash.sqldelight:coroutines-extensions` + `app.cash.sqldelight:sqlite-driver`. `*.sqm`), round-trip, race-conditions (concurrent append), paged
flow.
## Что в схеме (упрощённо)
```sql
CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
created_at TEXT NOT NULL, -- ISO Instant
kind TEXT NOT NULL, -- 'user', 'assistant', 'tool_call', 'tool_result'
body_json TEXT NOT NULL
);
CREATE INDEX idx_messages_conv_time ON messages(conversation_id, created_at);
CREATE TABLE working_memory (
conversation_id TEXT NOT NULL,
entry_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
kind TEXT NOT NULL,
body_json TEXT NOT NULL
);
```
Полная схема + миграции — в `src/jvmMain/sqldelight/`.
## Текущий статус
Используется продакшеном. Миграции 1.0+.