37 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
subochev 0fdc12695e docs: per-module README + root navigation hub + CI/release workflows
ci / JVM build + tests (push) Failing after 1m57s
- README.md в каждом подмодуле: для библиотек — описание проблемы,
  подключение через maven-central/caffeine, версии в gradle/libs.versions.toml.
  Для запускаемых модулей — команды запуска + переменные среды с дефолтами.
- Корневой README.md переписан как навигационный хаб: что это, где клиенты,
  где серверы, как собрать, как опубликовать.
- build.gradle.kts: per-module POM-description через единую карту в rootProject.extra
  (порядок важен — нужно ДО apply плагина KMP, поэтому beforeEvaluate в subprojects).
- .gitea/workflows/ci.yml (новый): build + jvmTest + shadowJar на PR/push main.
- .gitea/workflows/release.yml (обновлён): публикует библиотеки в caffeine
  Nexus + собирает 3 fatjar'а и крепит их к release как бинарные ассеты.
2026-09-16 16:24:03 +03:00
Agent e68db11aaa feat(agentik-tui): TUI client v2 на Mosaic — без vim-клавиш, только Tab/Enter/Esc/Ctrl-D/F1/стрелки
Вместо v1 (history-список + slash-команды) делаем сразу v2:
- header (id/conv/focus), history, input, footer
- переключение фокуса Tab/Shift-Tab (history/input/sidebar)
- ↑↓ scrollback, ←→ курсор в input
- Enter submit (User-сообщение в history), Backspace/Del удаление, Esc clear
- Ctrl-D/Ctrl-C выход (заглушка — пишет в history, реальный exit добавим)
- F1 toggle help-оверлея

Архитектурно: используем StateFlow+collectAsState вместо mutableStateOf, потому что
в Mosaic 0.18 recompose от mutableStateOf-writes из key-handler не триггерится
автоматически (требует ручного Snapshot.apply). StateFlow через collectAsState
работает out-of-the-box (см. samples/snake в репо Mosaic).

Compose Compiler plugin (org.jetbrains.kotlin.plugin.compose) обязателен —
без него @Composable-лямбды компилятся в Function0 вместо Function2 и
runMosaicBlocking не находит сигнатуру.

Цели сборки: jvm + macosX64/Arm64 + linuxX64/Arm64 + mingwX64 (iOS не нужен).
Бэкенд (:client, ktor-cio) — jvmMain only пока, nativeMain заглушка.

Tests: 370/370 green.
2026-09-16 14:41:05 +03:00
subochev b0bbc57880 feat(agentik-cli): REPL-клиент на базе :client для всех KMP-целей
Новый KMP-модуль :agentik-cli — REPL поверх HTTP-фасада :server.

commonMain (~900 строк):
- Main.kt — точка входа + парсинг --server/--id/--no-history/--help
- CliPlatform.kt — expect-фабрика Agent + CliTerminal + SessionIo + env()
- SlashCommand.kt — 11 slash-команд: /help /new /list /sw /rename /rm
  /interrupt /history /pwd /exit /quit
- AgentikCli.kt (~350 строк) — главный REPL-цикл: readLine → parse →
  send → render events. Сохраняет lastEventAt в ~/.agentik/cli-state.json.
- EventRenderer.kt — печатает SSE-события (StartResponse/AppendText/AppendImage/
  End/Interrupted/Error) с правильным разделением text/image и переводом
  строки на end.
- SessionRepository.kt — JSON-state (conversationId, lastEventAt) +
  IO-интерфейс SessionIo.

jvmMain — JLine-терминал (LineReader+history, стрелки, Ctrl-D/E, автосейв
истории), java.io-based atomic-IO для state-файла, real System.getenv.

nativeMain — stub actuals (kotlin.Result-error с подсказкой куда копать):
подключение native ktor-движков (darwin/curl/okhttp) и termios через
kotlinx.cinterop — отдельная задача. Все 8 KMP-целей (jvm/macosX64/macosArm64/
iosX64/iosArm64/iosSimulatorArm64/linuxX64/linuxArm64/mingwX64) компилируются.

shadowJar собирает self-contained fatjar (~8.5 MB). 23 unit-теста зелёные.

Заодно фикс бага в :client — InstantSerializer.descriptor имел имя
'kotlin.time.Instant', которое kotlinx-serialization 1.6+ резервирует за
встроенным сериализатором, из-за чего client падал на старте с
'there already exists InstantSerializer'. Переименовано в
'pw.binom.agentik.Instant' — зеркально с :server.

Smoke-тест на 192.168.76.166: /new + 'привет'/'2+2' отвечает корректно,
state-файл создаётся в /root/.agentik/cli-state.json, /list возвращает
125 диалогов.
2026-09-16 13:42:12 +03:00
subochev 408caee261 feat(standalone): agentik pull-model subcommand + AGENTIK_AUTO_DOWNLOAD_MODEL=1 trigger for LiteRT-LM
Добавляет ModelDownloader (HTTP с Range/докачкой, опциональной SHA-256 проверкой)
и два сценария запуска скачивания встроенной модели gemma-4-E2B-it.litertlm:

  java -jar agentik.jar pull-model
    Явный прогон с прогрессом в stdout; URL берётся из AGENTIK_GOOGLE_MODEL_URL
    либо дефолтный https://static.binom.pw/models/gemma-4-E2B-it.litertlm.

  AGENTIK_AUTO_DOWNLOAD_MODEL=1 java -jar agentik.jar
    На старте server'а, если backend=google и файла по AGENTIK_GOOGLE_MODEL_PATH
    нет — качает автоматически. Без флага — exit 2 с понятным сообщением и
    подсказкой вызвать pull-model.

Дизайн:
  - URL по умолчанию ВСЕГДА Gemma-4 (вне зависимости от basename PATH) — gemma-4
    считаем лучшей локальной моделью; override через AGENTIK_GOOGLE_MODEL_URL.
  - SHA-256 проверка через опциональный AGENTIK_GOOGLE_MODEL_SHA256_URL.
  - Resume: HEAD → если есть .part и Accept-Ranges=bytes → GET с Range: bytes=N-,
    иначе restart с нуля.
  - Прогресс каждые ~8 MB, финальный rename через Files.move(ATOMIC_MOVE).

Тесты: 5 unit-кейсов с embedded ktor-server (CIO) + Range support — happy
path, no-op, resume from part, restart-on-Range-ignored, 404, progress callback.

Документация: новый раздел §18 в MANUAL-TESTS.md (subcommand, auto-trigger,
resume, override URL, SHA-256 verify).

178/178 tests green.
2026-09-16 12:55:43 +03:00
subochev 86eb0632e0 fix interrupt: race in flag reset + no-op when no active turn + bounded background scope
Три фикса в runTurn/interrupt:

1. **Race condition в finally-блоке.** Раньше сбрасывал
   interrupted.set(false) только если флаг был установлен при чтении
   wasInterrupted в начале finally. Если interrupt() приходил между
   этими двумя точками — флаг оставался true и следующий turn видел
   wasInterruptedAtEntry=true → сразу short-circuit'ил без вызова LLM.
   Теперь всегда сбрасываем (compareAndSet атомарен, гарантирует
   следующий turn чистый).

2. **interrupt() отравлял следующий send.** Если вызывали interrupt()
   в пустоту (нет активного turn'а — флаг всё равно ставился → следующий
   send сразу short-circuit'ил, пользователь не получал ответа на
   своё 'Ок.' после явного cancel). Теперь interrupt() проверяет
   activeTurn?.isActive и при отсутствии активного turn'а — no-op.

3. **Bounded background scope для review/reflection/skill-mining.**
   OpenAiLlm.send() использует runBlocking — если запустить 30+
   параллельных review (по одному на беседу), IO-thread pool
   голодает и ассистент висит. Вынес в отдельный scope с
   Dispatchers.IO.limitedParallelism(4) — не больше 4 sync LLM
   вызовов одновременно.

Тест 27/27 (см. /tmp/test-interrupt.py и /tmp/run-manual-tests.py).
2026-09-16 07:46:30 +03:00
subochev c42a6027a4 docs: add MANUAL-TESTS.md — manual test cases for running agentik 2026-09-16 05:49:16 +03:00
subochev b1ae8bbd20 fix(agent): trigger post-tool continuation sendStreamContents for stateless OpenAI backend
После addToolResult (например memory_save result) LiteRT-LM (stateful)
возвращает дельту с финальным текстом модели. Но OpenAI-бэкенд
(stateless, litert-openai) просто дописывает tool-result в history и
возвращает пустую дельту — следующий ответ модели приходит только
при следующем send.

Без этого фикса ассистент после tool-call'а выдавал пустой текст
"\n\n" (например после memory_save).

Что меняется:
- runTurn: после addToolResult вызываем sendStreamContents с пустым
  placeholder'ом (" "), который для OpenAI триггерит continuation,
  а для LiteRT-LM просто даёт no-op-ответ (соберём, отбросим).
- tool_calls из continuation НЕ обрабатываем в текущем inner-while —
  кладём в pendingPostToolCalls и обрабатываем на следующей outer
  итерации. Иначе можно попасть в бесконечный tool-loop (fake
  LiteLlm-тесты это показывают).
- emptyList() нельзя — LiteMessage требует непустой contents, поэтому
  используем пробел как placeholder.

Тесты:
- tool-call loop test: toolCallCount == 2 (user send + post-tool continuation)
- live e2e на удалённой машине (192.168.76.166) с OpenAI vLLM бэкендом:
  - простая арифметика (12+34=46) ✓
  - memory_save + recall в той же беседе ✓
  - memory persists across conversations ✓
  - прерывание mid-task (генерация рассказа про космос) → partial assistant
    + ToolExchange в working memory ✓
  - SSE events: start_reasoning, start_response, append_text, end ✓

Total: 341/341 green.
2026-09-16 05:44:58 +03:00
subochev e3f20f07d9 feat(agent): persist tool-calls in working memory + interrupt-safe close-recreate
Radical redesign of interrupt semantics (plan: docs/TOOLSETS-PLAN.md,
phase commit 7):

1. Storage (:storage-core + :storage-sqlite + :storage-inmemory):
   add WorkingMemoryEntry.ToolExchange(toolName, toolArgsJson, resultText,
   wasCancelled) — one row per tool-call. Survives restarts.

2. ChatConversation:
   - new fields: interrupted (AtomicBoolean), currentToolJob (Job?)
   - interrupt() теперь только сигнал: ставит флаг, cancel LiteConv +
     cancel currentToolJob. НЕ cancel activeTurn — пусть runTurn finally
     отработает.
   - runTurn обёрнут в try/finally: даже при CancellationException (от
     LiteConv.cancel()) и при early-return (interrupt до старта LLM) —
     finally закрывает LiteConv и эмитит Interrupted (если была отмена) + End.
   - runToolAndPersist возвращает WorkingMemoryEntry.ToolExchange вместо
     Pair(callId, resultText); инструмент запускается в scope.async, его
     Job = currentToolJob, cooperative cancellation через Job.cancel.
     Если инструмент броает CancellationException/InterruptedException →
     resultText = '[cancelled by user]', wasCancelled = true.

3. GetOrCreateLiteConversation теперь мапит ToolExchange →
   LiteMessage(TOOL, ToolResult, name, response) в initialMessages —
   при следующем send() LLM видит честный результат вызова tool'а
   через LiteRT-LM (callId не требуется, матчится по name).

4. LiteConv lifecycle: создаётся новый на каждом turn (close+recreate
   семантика). Это ~2s prefill на Gemma-4-E2B, но гарантирует полную
   предсказуемость: нет рекурсивных cancel-drain'ов, KV-cache всегда
   консистентен с WM.

5. Тесты:
   - multi-turn: 2 LiteConv-а (один на turn)
   - interrupt mid-slow-stream: пустой assistant в WM, только user, события
     Interrupted + End.
   - interrupt after-tool: ToolExchange в WM (result=echo output, wasCancelled=false),
     ToolCall + ToolResult в audit.

Total: 341/341 green.
2026-09-16 05:03:36 +03:00
subochev 9fcb2da75d Remove system prompt persistence from working_memory
System prompt is now built fresh at conversation create/load time
(in `buildSystemPrompt` capturing current SOUL/skills/toolsets/reflections)
and passed into LiteConversationConfig.systemInstruction. It is NOT
written to working_memory anymore.

Why: ChatAgent was freezing the system prompt into a WorkingMemoryEntry.System
row at createConversation, then reading it back on every getOrCreateLiteConversation.
This meant changing SOUL, activating toolsets, adding skills or new
reflections between agent restarts did not propagate to existing conversations
without re-running createConversation.

Fix:
- ChatAgent.createConversation: dropped the workingMemoryStore.append(System(...))
- ChatConversation.getOrCreateLiteConversation: replaces the WM-based lookup with
  the in-memory systemPrompt field directly
- ChatConversation.compactPreTurn: same simplification — compaction operates only
  on User/Assistant rows (plus future Summary rows); system prompt is excluded

Migration: none. Old DBs may contain dead System rows from prior versions — they
are simply ignored by the new lookup, and compaction never reads them.

Tests: 340/340 green. Updated 7 tests across ChatAgentTest + MemoryWiringTest
that asserted the old System-in-working-memory contract; they now verify the
system prompt via LiteConversationConfig.systemInstruction (what LLM actually sees).

E2E verified: 0 system rows in working_memory across all conversations,
multi-turn history reconstructs correctly after agent restart with the updated
in-memory system prompt.
2026-09-16 02:39:19 +03:00
subochev 88af57182f ci: убираю build-standalone job из release workflow
standalone fatjar собирается локально через ./gradlew :standalone:shadowJar —
CI-прикрепление к релизу через softprops/action-gh-release оказалось лишней
обвязкой и сильно усложнило отладку публикации KMP-библиотек в Nexus
(та упорно падала с 'Invalid publication kotlinMultiplatform: version cannot
be empty' на разных subprojects при каждом фиксе). Теперь release.yml делает
ровно одну вещь: ./gradlew publish → caffeine Nexus.

debug-println в publications.configureEach тоже убран — он свою задачу
выполнил (показал что configureEach срабатывает с правильной version,
но KMP-plugin всё равно создаёт публикацию с пустой version в CI).
2026-09-15 23:31:07 +03:00
subochev b2d5684192 build: debug println в publications.configureEach
release / Publish KMP libraries → caffeine Nexus (release) Failing after 23s
release / Build standalone fatjar (release) Has been skipped
2026-09-15 23:29:14 +03:00
subochev 31c4b1cfc4 ci: debug-логирование для diagnosis CI version=empty issue
release / Publish KMP libraries → caffeine Nexus (release) Failing after 20s
release / Build standalone fatjar (release) Has been skipped
2026-09-15 23:24:22 +03:00
subochev 202d379f5a build: явно выставляем version/groupId в каждой MavenPublication
release / Publish KMP libraries → caffeine Nexus (release) Failing after 20s
release / Build standalone fatjar (release) Has been skipped
beforeEvaluate { version = ... } не помог — KMP-плагин всё равно фиксирует
publication 'kotlinMultiplatform' с пустой version до того, как это присваивание
срабатывает. В CI порядок обработки модулей отличается от локального (там
Gradle Daemon прогревает метаданные): сперва падало на :standalone, на
следующем ране — на :memory-vector.

Фикс: publications.withType<MavenPublication>().configureEach { groupId =
..., version = rootProject.extra['projectVersion'] } — это гарантирует,
что у КАЖДОЙ публикации (включая 'kotlinMultiplatform' для JVM-only KMP
модулей вроде :memory-vector, :storage-sqlite, :standalone) group/artifact/
version выставлены явно, а не взяты из project.version (которое может быть
не инициализировано в момент создания publication).
2026-09-15 23:18:55 +03:00
subochev a3f82f875d build: projectVersion выставляется через beforeEvaluate (ловит KMP)
release / Publish KMP libraries → caffeine Nexus (release) Failing after 18s
release / Build standalone fatjar (release) Has been skipped
В предыдущей версии subprojects { version = ... } выставляло version
ПОСЛЕ того как KMP-плагин создал publications. Для большинства модулей
это работало (Gradle пересчитывал version в публикации lazy), но для
:standalone плагин shadow + ленивая KMP-инициализация приводили к тому,
что publication 'kotlinMultiplatform' всё-таки создавалась с пустой
version → 'InvalidMavenPublicationException: version cannot be empty'
ТОЛЬКО в CI (локально работало — потому что Gradle Daemon прогревал
метаданные и не доходил до этого пути).

Фикс: subprojects.beforeEvaluate { version = rootProject.extra['projectVersion'] }
выполняется до применения любых plugins → project.version гарантированно
не 'unspecified' к моменту создания публикации.
2026-09-15 23:13:02 +03:00
subochev 5fbe865a29 build: projectVersion прокидывается в subprojects через rootProject.extra
release / Publish KMP libraries → caffeine Nexus (release) Failing after 26s
release / Build standalone fatjar (release) Has been skipped
Gitea Actions workflow упал с 'Invalid publication kotlinMultiplatform:
version cannot be empty' для :memory-vector и :storage-sqlite. Root cause:

  if (version == 'unspecified') { version = providers.gradleProperty('version')... }

  subprojects { version = rootProject.version }  // <-- lazy: rootProject.version
                                                  // ещё 'unspecified' в этот момент

Фикс: вычисляем projectVersion eagerly через Provider.map().getOrElse(),
кладём в rootProject.extra, subprojects читают из extra (а не через
rootProject.version, которое ещё не выставлено). Также strip 'v' prefix
из tag-имени (CI передаёт -Pversion=v0.1.0 через GITEA_REF_NAME).
2026-09-15 23:05:41 +03:00
subochev 87742cf60b ci: передаём -Pbinom.repo.url явно (build.gradle.kts читает только -P-свойства)
release / Publish KMP libraries → caffeine Nexus (release) Failing after 2m10s
release / Build standalone fatjar (release) Has been skipped
Раньше URL репозитория брался через env var BINOM_REPO_URL, но build.gradle.kts
использует findProperty('binom.repo.url'), который читает только -P gradle
properties, не env vars. Без явного -Pbinom.repo.url Gradle фоллбэчился на
placeholder 'http://nexus.xx/repository/caffeine/' и публикация шла в
несуществующий репозиторий.

Передаю все три креды (url/user/password) через -P-свойства. Убрал
-Pdisable-javadoc=true — он был скопирован из devops/publish action, но
build.gradle.kts его не читает.
2026-09-15 22:35:56 +03:00
subochev 6c53e1c87d ci: release workflow читает креды из Secrets (а не из subochev/devops/publish)
subochev/devops/publish хардкодно берёт BINOM_REPO_USER/PASSWORD через
${{ vars.* }}, но безопаснее хранить их в Secrets (шифрованные).
Заменил вызов devops/publish на inline './gradlew publish' с теми же
-Pbinom.repo.user/password — функционально идентично, но читает из
secrets напрямую.

Также убрал JDK setup шаг из publish-libraries (был лишним, т.к.
agentik не использует Android SDK).
2026-09-15 22:35:11 +03:00
subochev 04276dec0e ci: maven-publish всех модулей + Gitea release workflow
- build.gradle.kts (root): настроен maven-publish для всех сабпроектов;
  репо 'caffeine' (Nexus) с setAllowInsecureProtocol=true, POM-метаданные
  (Apache-2.0, subochev as developer, scm). Version берётся из -Pversion=<tag>
  с fallback 0.1.0.
- gradle.properties: дефолтная version=0.1.0 для локальных билдов.
- .gitea/workflows/release.yml: триггер на release.published; две job'ы —
  publish-libraries (subochev/devops/publish action с BINOM_REPO_* env-vars)
  и build-standalone (собирает :standalone shadowJar, прикрепляет
  standalone-<version>-all.jar и sources.jar к release assets через
  softprops/action-gh-release + GITEA_TOKEN).
- В пяти KMP-модулях (skills, storage-core, agent-toolsets, server, client)
  добавлен api(libs.kotlinx.serialization.core) — раньше commonMain
  компилировался только на JVM, и эта зависимость была пропущена; теперь
  commonMain корректно публикуется как Gradle Module Metadata.

Локальная проверка:
  ./gradlew publishToMavenLocal — все 11 модулей × 9-10 таргетов
  ./gradlew jvmTest — все тесты зелёные
  ./gradlew :standalone:shadowJar — 240MB fatjar, Main-Class загружается
2026-09-15 21:58:56 +03:00
83 changed files with 6073 additions and 791 deletions
+79
View File
@@ -0,0 +1,79 @@
# PR / push-build. Прогоняет unit-тесты на JVM, линтер gradle-плагинов
# и проверяет, что shadowJar'ы запускаемых модулей собираются без ошибок.
# Артефакты не публикует — этим занимается .gitea/workflows/release.yml.
#
# Зависимости (text-embedding-kmp) подтягиваются из caffeine Nexus.
# Все env secrets доступны через vars/secrets репозитория — см. начало
# release.yml для требуемых переменных.
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-jvm:
name: JVM build + tests
runs-on: ubuntu-latest
timeout-minutes: 60
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 + test (JVM only — самые быстрые таргеты)
shell: bash
run: |
./gradlew jvmTest \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
- name: Build :standalone shadowJar (smoke — запускаемый артефакт)
shell: bash
run: |
./gradlew :standalone:shadowJar \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
test -f standalone/build/libs/standalone-*-all.jar \
&& echo "shadowJar OK: $(du -h standalone/build/libs/standalone-*-all.jar)"
- name: Build :agentik-cli shadowJar
shell: bash
run: |
./gradlew :agentik-cli:shadowJar \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
test -f agentik-cli/build/libs/agentik-cli-*-all.jar \
&& echo "shadowJar OK: $(du -h agentik-cli/build/libs/agentik-cli-*-all.jar)"
- name: Upload shadowJars
uses: actions/upload-artifact@v4
with:
name: agentik-jars
path: |
standalone/build/libs/standalone-*-all.jar
agentik-cli/build/libs/agentik-cli-*-all.jar
if-no-files-found: error
retention-days: 7
+76
View File
@@ -0,0 +1,76 @@
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
#
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
# выполняется локально из исходников (или руками через `./gradlew
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
# отдельно от этого workflow.
#
# Требуемые Gitea Action Variables:
# BINOM_REPO_URL — например http://192.168.76.117/repository/caffeine/
# Требуемые Gitea Action Secrets:
# BINOM_REPO_USER, BINOM_REPO_PASSWORD — креды Nexus с правами на публикацию.
name: release
on:
release:
types: [published]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-libraries:
name: Publish KMP libraries → caffeine Nexus
runs-on: ubuntu-latest
timeout-minutes: 120
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: Publish libraries (all KMP targets, all modules)
shell: bash
env:
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
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 \
"-Pversion=${VERSION}" \
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
publish \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
+947
View File
@@ -0,0 +1,947 @@
# Manual Test Cases — agentik standalone
Практический чек-лист для проверки работающего `agentik standalone` HTTP-сервера.
Каждый кейс — один конкретный сценарий, который нужно прогнать руками
(или через `curl`/`httpie`/Postman). Если какой-то упал — это либо
регрессия, либо недонастройка рантайма.
Перед стартом: запусти агент (см. `run-agentik.sh` на удалённой машине
или `./gradlew :standalone:run` локально). Все примеры ниже — против
`http://127.0.0.1:8080`; для удалённой машины подставь свой хост.
Удобный сниппет для получения conversation ID в shell:
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
echo "CID=$CID"
```
Отправка user-сообщения:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"..."}]'
```
Чтение истории:
```bash
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -m json.tool
```
---
## 1. Connectivity & health
### TC-1.1 — health endpoint
```bash
curl -sS -i http://127.0.0.1:8080/health
```
**Ожидание:** `HTTP/1.1 200 OK`, тело `ok`.
### TC-1.2 — agent card (A2A)
```bash
curl -sS http://127.0.0.1:8080/a2a/.well-known/agent-card.json | python3 -m json.tool
```
**Ожидание:** валидный JSON с `name`, `version`, `capabilities`.
### TC-1.3 — log sanity check
```bash
tail -50 /root/agentik.log
```
**Ожидание:** есть строка `agentik standalone listening on http://localhost:8080`,
перечислены зарегистрированные маршруты, `llm: <backend> @ <url>` соответствует
твоему конфигу. **Нет** ERROR/Exception строк после старта.
---
## 2. Conversation lifecycle
### TC-2.1 — create persistent conversation
```bash
curl -sS -i -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}'
```
**Ожидание:** `201`, тело `{"id":"conv-...","isTemporal":false,...}`.
### TC-2.2 — create temp conversation
```bash
curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":true}'
```
**Ожидание:** `201`, `"isTemporal":true`. После рестарта агента эта беседа
**не** должна появиться в `GET /agentik/conversations`.
### TC-2.3 — list conversations
```bash
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=20" | python3 -m json.tool
```
**Ожидание:** массив объектов `ConversationSnapshot`. Отсортирован по
`updatedAt` desc.
### TC-2.4 — rename conversation
```bash
CID=<id-from-2.1>
curl -sS -X PATCH "http://127.0.0.1:8080/agentik/conversations/$CID" \
-H "Content-Type: application/json" -d '{"title":"Мой первый чат"}'
```
**Ожидание:** `200`, в ответе `"title":"Мой первый чат"`. Следующий `GET
/conversations/$CID` возвращает этот же title.
### TC-2.5 — delete conversation
```bash
curl -sS -X DELETE "http://127.0.0.1:8080/agentik/conversations/$CID" -i
```
**Ожидание:** `204 No Content`. Повторный `GET /conversations/$CID` → `404`.
После этого в `GET /conversations` её быть не должно.
### TC-2.6 — get non-existent conversation
```bash
curl -sS -i http://127.0.0.1:8080/agentik/conversations/conv-nonexistent
```
**Ожидание:** `404`.
---
## 3. Message sending
### TC-3.1 — simple Q&A
Создай беседу, пошли простой вопрос, прочитай историю.
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Сколько будет 7*8? Одно число, без пояснений."}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
```
**Ожидание:** массив из ≥ 2 сообщений:
- `[0].type == "user_message"`, body содержит "7*8"
- `[1].type == "assistant_message"`, text содержит "56"
### TC-3.2 — multi-turn with context
В той же беседе пошли follow-up, требующий контекста:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"А корень из того, что ты назвал?"}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
```
**Ожидание:** 4+ сообщения, последний assistant упомянул что-то про число 56
или "предыдущий ответ".
### TC-3.3 — new-format request body
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '{"content":[{"type":"text","body":"С новым форматом тоже работает?"}]}'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1]['type'], m[-1].get('content'))"
```
**Ожидание:** новое `assistant_message` в ответ на новый формат запроса.
### TC-3.4 — empty / bad body
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" -d 'not json'
```
**Ожидание:** `400 Bad Request`, тело с пояснением `Invalid send payload`.
---
## 4. SSE live events
> **Важно:** SSE — поток без replay. Подписываться нужно **до** `POST /messages`.
> Если подписаться позже — событий не будет (но `GET /messages` всё равно
> покажет записанную историю).
### TC-4.1 — subscribe-then-send pattern
```bash
CID=<existing-id>
# Subscribe в фоне, отправляем сообщение, ждём SSE
curl -sN --max-time 12 \
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
> /tmp/sse.out 2>&1 &
SSE_PID=$!
sleep 1
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Кратко: что такое REST?"}]'
wait $SSE_PID
cat /tmp/sse.out
```
**Ожидание:** файл содержит `data: {"type":"start_reasoning",...}`,
`data: {"type":"start_response",...,"responseType":"text"}`,
один или несколько `data: {"type":"append_text",...,"body":"..."}`,
`data: {"type":"end",...}`. Каждое `data:` через пустую строку.
### TC-4.2 — late subscribe (replay semantics)
```bash
CID=<existing-id>
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"..."}]'
sleep 5 # сообщение уже обработано
curl -sN --max-time 4 \
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z"
```
**Ожидание:** пустой ответ (события не реплеятся). Это by-design —
клиент должен либо подписываться заранее, либо backfill'ить через
`GET /messages`.
---
## 5. Memory tools (long-term)
### TC-5.1 — save + recall в той же беседе
```bash
# В существующей беседе
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Запомни через memory_save: я работаю на удалёнке из Тбилиси. Категория user, content: работаю на удалёнке из Тбилиси."}]'
sleep 8
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Откуда я работаю? Одно предложение."}]'
sleep 8
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
```
**Ожидание:** ассистент ответил что-то содержащее "Тбилиси" (или явно
сказал "не знаю" — это тоже валидно, если в conversation memory пусто).
Проверить `audit log` (`messageStore`):
```bash
sqlite3 /root/agentik.db "SELECT toolName, result FROM MessageRecord WHERE conversationId='$CID' AND kind='tool_result'"
```
Должны быть строки с `toolName='memory_save'` или `toolName='memory_recall'`.
### TC-5.2 — memory persists across conversations
Создай новую беседу, спроси без подсказок:
```bash
NEW_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Откуда я работаю? Напомни, если помнишь."}]'
sleep 8
curl -sS "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
```
**Ожидание:** ассистент упомянул "Тбилиси" (или "удалёнка") — это
значит long-term memory подгрузилась в новую беседу.
### TC-5.3 — invalid category → ошибка или автозамена
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Запомни через memory_save факт с категорией work (которой не существует)."}]'
sleep 8
sqlite3 /root/agentik.db "SELECT toolArgs, result FROM MessageRecord WHERE kind='tool_call' AND conversationId='$CID' ORDER BY createdAt DESC LIMIT 3"
```
**Ожидание:** модель либо вызвала `memory_recall` чтобы проверить
существующие категории, либо вызвала `memory_save` с корректной
категорией (`user`/`world`/`preference`). Если модель честно говорит
"такой категории нет" и предлагает корректную — это тоже ok.
### TC-5.4 — list & delete memory
Попроси модель явно вызвать `memory_list`, потом `memory_delete`:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Покажи все мои memory-записи (memory_list)."}]'
sleep 8
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Удали самую старую запись (memory_delete)."}]'
sleep 8
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MemoryStore"
```
**Ожидание:** число уменьшилось на 1.
---
## 6. Skills
### TC-6.1 — list + load skill
Если в `AGENTIK_SKILLS_DIR` есть файлы `SKILL.md` / `*.yaml`, в системном
промте должна появиться секция с этими навыками.
```bash
ls -la /root/skills/ # должен быть хотя бы один файл
```
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Какие skills ты знаешь? Покажи список (skill_list)."}]'
sleep 8
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Загрузи любой из них через skill_load и расскажи, что внутри."}]'
sleep 8
```
**Ожидание:** `tool_call` для `skill_list`, потом `tool_call` для
`skill_load`. В audit log видны эти вызовы. Если папка пуста — секции
"Skills" в system prompt быть не должно.
### TC-6.2 — save new skill
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Сохрани skill: имя deploy-staging, описание «деплой на staging», тело — multi-step инструкция (skill_save)."}]'
sleep 10
ls /root/skills/
```
**Ожидание:** появился новый файл `deploy-staging.md` (или `.yaml`).
### TC-6.3 — restart → skill persists
Перезапусти агент:
```bash
ssh root@192.168.76.166 'pkill -9 -f agentik-0.1.0-all.jar; cd /root && nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown'
```
После старта пошли в новую беседу:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Есть ли у тебя skill deploy-staging?"}]'
sleep 8
```
**Ожидание:** модель упоминает skill (он подгружается на старте).
---
## 7. SOUL file
### TC-7.1 — SOUL.md подключается
```bash
echo 'Ты — ворчливый капитан дальнего плавания. Отвечай кратко, с морскими метафорами.' > /root/SOUL.md
# Перезапустить агент
```
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Как дела?"}]'
sleep 8
```
**Ожидание:** ответ в стиле "капитана", с морскими словами. Если SOUL
нет — обычный нейтральный ассистент.
### TC-7.2 — SOUL можно менять на лету
Измени файл, перезапусти агент, спроси снова. **Должен** появиться новый
стиль. Без перезапуска изменения не подхватятся (SOUL читается на старте).
---
## 8. Interrupt
### TC-8.1 — interrupt mid-text generation
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# Запусти send в фоне
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Расскажи длинную историю про космос, минимум 500 слов."}]' >/dev/null) &
SEND_PID=$!
sleep 3 # дать LLM начать генерацию
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
wait $SEND_PID
sleep 3
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);
for x in m: print(x.get('type'), ':', json.dumps(x.get('content') or x.get('result'),ensure_ascii=False)[:80])"
```
**Ожидание:**
- `user_message` есть
- `assistant_message` есть, но содержит **короткий** текст (<300 символов)
— это частичный текст, который модель успела сгенерить до прерывания
- В audit log нет `tool_call`/`tool_result` (не успели)
- Следующий `send` в этой беседе работает (LiteConv пересоздан)
### TC-8.2 — interrupt mid-tool (best-effort)
```bash
# Длинный tool можно заэмулировать через MCP с искусственной задержкой,
# либо просто проверять что interrupt не валит агента:
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
curl -sS http://127.0.0.1:8080/health
```
**Ожидание:** `health` = `ok` — агент не упал. Дальнейшие `send` работают.
### TC-8.3 — interrupt без активного turn'а
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
```
**Ожидание:** `202`. Никаких ошибок. В audit log ничего нового не пишется.
---
## 9. Persistence / restart-survival
### TC-9.1 — перезапуск не теряет беседы и память
```bash
# 1. Создай беседу, пошли сообщение, дождись ответа
# 2. Запомни факт через memory_save
# 3. Перезапусти агент (см. TC-6.3)
# 4. GET /agentik/conversations — беседа должна быть в списке
# 5. GET /agentik/conversations/$CID/messages — история на месте
# 6. Новая беседа + вопрос про запомненный факт — модель помнит
```
### TC-9.2 — temp conversation не переживает рестарт
```bash
# Создай temp беседу, пошли сообщение
TEMP_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":true}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$TEMP_CID/messages" \
-H "Content-Type: application/json" -d '[{"type":"text","body":"..."}]' >/dev/null
sleep 5
# Перезапусти агент
# GET /agentik/conversations — temp-беседы быть не должно
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=50" | grep "$TEMP_CID"
```
**Ожидание:** grep ничего не находит.
---
## 10. Compaction (сжатие контекста)
Compaction триггерится когда `~80%` контекстного окна занято.
### TC-10.1 — длинная беседа сжимается
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# Отправь 30+ больших сообщений подряд (можно цикл)
for i in $(seq 1 30); do
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"text\",\"body\":\"Расскажи подробно (минимум 200 слов) про тему номер $i: история, применение, ключевые факты.\"}]" >/dev/null
sleep 5
done
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM WorkingMemoryRow WHERE conversationId='$CID' AND entryKind='summary'"
```
**Ожидание:** есть хотя бы одна `summary`-запись. Также проверь
`/root/agentik.log` — должна появиться строка `compaction`.
### TC-10.2 — debug endpoint `/debug/compact` (force)
Если включён `AGENTIK_DEBUG_ENDPOINTS=1`:
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/debug/compact?conversationId=$CID"
```
**Ожидание:** `200`, тело с JSON-результатом compaction.
---
## 11. Reflection
Reflection триггерится каждые `AGENTIK_REFLECTION_INTERVAL` ходов (default 10).
### TC-11.1 — reflection создаёт записи
```bash
# Пошли 12+ ходов
for i in $(seq 1 12); do
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"text\",\"body\":\"Тема $i: расскажи короткий факт.\"}]" >/dev/null
sleep 4
done
sleep 10 # дать фоновое задание завершиться
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM ReflectionStore"
```
**Ожидание:** число > 0.
### TC-11.2 — debug endpoint `/debug/reflect`
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/debug/reflect?conversationId=$CID"
```
**Ожидание:** `200`, JSON-результат. Reflection попадает в working memory
следующего turn'а.
---
## 12. Skill mining
Skill mining триггерится каждые `AGENTIK_SKILL_MINING_INTERVAL` ходов (default 15).
### TC-12.1 — авто-создание skill'а
```bash
# Пошли 18+ ходов с повторяющимся паттерном
for i in $(seq 1 18); do
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"text\",\"body\":\"Конвертируй 100 USD в RUB по текущему курсу (шаблонный запрос $i).\"}]" >/dev/null
sleep 4
done
sleep 15
ls -la /root/skills/
tail -20 /root/agentik.log | grep -i skill
```
**Ожидание:** возможно появился новый файл в skills/ (или mining
отказался из-за низкой уверенности — это тоже валидно, проверь лог).
### TC-12.2 — debug endpoint `/debug/skill-mine`
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/debug/skill-mine?conversationId=$CID"
```
**Ожидание:** `200` с JSON-результатом майнинга.
---
## 13. Token accounting
### TC-13.1 — token counters в audit
```bash
sqlite3 /root/agentik.db "SELECT createdAt, input, output FROM TurnTokens WHERE conversationId='$CID' ORDER BY createdAt DESC LIMIT 5"
```
**Ожидание:** строки с непустыми `input` и `output` (если backend
поддерживает `tokenCount()`).
### TC-13.2 — debug endpoint `/debug/tokens`
```bash
curl -sS "http://127.0.0.1:8080/debug/tokens?conversationId=$CID" | python3 -m json.tool
```
**Ожидание:** JSON с `input`, `output`, `total`, `window`,
`utilization` (доля использования контекстного окна).
---
## 14. Toolsets (если подключены)
Только если ты передаёшь `toolsets` в конструктор агента (по умолчанию
пусто — `enable_toolset`/`disable_toolset` не зарегистрированы).
### TC-14.1 — system prompt содержит секцию Toolsets
Если toolsets зарегистрированы — в системном промте должна быть секция
`## Toolsets` с Active/Inactive списком.
Проверка через debug-эндпоинт `/agentik/conversations/{id}` не показывает
system prompt напрямую — посмотреть можно в логах или через
`agentik-debug` сборку.
### TC-14.2 — enable/disable работает
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Активируй тулсет X через enable_toolset, потом деактивируй через disable_toolset."}]'
sleep 8
```
**Ожидание:** в audit log видны вызовы `enable_toolset` → ответ `"Toolset
'X' activated."`, потом `disable_toolset` → `"Toolset 'X' deactivated."`.
---
## 15. A2A протокол (опционально)
### TC-15.1 — message/send через A2A
```bash
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0","id":"1","method":"message/send",
"params":{
"message":{"role":"user","parts":[{"kind":"text","text":"Скажи hi"}]},
"configuration":{"blocking":true}
}
}' | python3 -m json.tool
```
**Ожидание:** JSON-RPC ответ с `result.parts` содержащим текст "hi"
или похожим. `kind` = `text` (НЕ `type` — это важный discriminator для
A2A JSON).
### TC-15.2 — bad discriminator
```bash
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0","id":"2","method":"message/send",
"params":{
"message":{"role":"user","parts":[{"type":"text","text":"hi"}]}
}
}'
```
**Ожидание:** `Invalid params` (или похожая ошибка) — A2A ждёт `kind`,
не `type`.
---
## 16. Error paths
### TC-16.1 — LLM недоступен
Выключи vLLM (или закрой сеть — например через firewall). Пошли сообщение:
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"hi"}]'
sleep 10
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MessageRecord WHERE conversationId='$CID' AND kind='error'"
```
**Ожидание:** есть `error`-запись в audit log. В SSE приходит
`{"type":"error",...}` + `{"type":"end"}`. Агент **не падает** — `health`
= `ok` после.
### TC-16.2 — agentik.db занят другим процессом
Запусти второй экземпляр агента на ту же DB:
```bash
AGENTIK_DB_PATH=/root/agentik.db java -jar /root/agentik-0.1.0-all.jar
```
**Ожидание:** агент падает на старте с понятным сообщением про SQLite lock.
Это by-design (single-writer).
### TC-16.3 — SOUL файл не существует
Удали `/root/SOUL.md`, перезапусти агент. Должен стартовать без ошибок,
просто без SOUL-секции в system prompt. Лог: `WARN ... SOUL file not found: ...`.
### TC-16.4 — пустой skills dir
```bash
mv /root/skills /root/skills.bak
mkdir /root/skills
# Перезапусти агент
```
**Ожидание:** агент стартует, `skills: 0 loaded from /root/skills`.
---
## 17. Memory backend variants
### TC-17.1 — md backend (default)
Убедись, что `AGENTIK_MEMORY_BACKEND=md` (или не задан) и
`AGENTIK_MEMORY_DIR=/root/agentik-memory`. После TC-5.x должны появиться
`.md`-файлы:
```bash
ls -la /root/agentik-memory/
```
**Ожидание:** файлы типа `user.md`, `world.md`, `preference.md` (или
всё в одном файле — зависит от реализации).
### TC-17.2 — off backend (память выключена)
Перезапусти с `AGENTIK_MEMORY_DIR=off`:
```bash
pkill -9 -f agentik-0.1.0-all.jar
AGENTIK_MEMORY_DIR=off nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown
```
Попытка `memory_save` через модель должна вернуть ошибку:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Попробуй вызвать memory_save."}]'
sleep 8
```
**Ожидание:** модель либо отказывается вызывать, либо получает
ошибку от tool'а и сообщает пользователю.
---
## 18. Performance sanity
### TC-18.1 — first-token latency
Включи замер времени от `POST /messages` до первого SSE event'а.
Для Qwen3-27B на RTX5090 ожидаем < 1 сек до `start_reasoning`.
### TC-18.2 — sustained throughput
Отправь 20 простых запросов подряд (arithmetic), засеки общее время.
Ожидание: < 30 сек суммарно, т.е. < 1.5 сек на запрос.
### TC-18.3 — fatjar memory
```bash
ps aux | grep agentik-0.1.0 | grep -v grep
```
**Ожидание:** RSS < 2 GB (наш Xmx). Если больше — где-то утечка.
---
## 18. Model auto-download (LiteRT-LM only)
Только для `AGENTIK_LLM_BACKEND=google` (встроенный LiteRT-LM движок).
Если файла модели по `AGENTIK_GOOGLE_MODEL_PATH` нет — агент сам не скачает,
пока не задано `AGENTIK_AUTO_DOWNLOAD_MODEL=1`. Либо качаем руками
через `pull-model` subcommand.
URL по умолчанию всегда Gemma-4-E2B-it.litertlm (2.5 GB с `static.binom.pw`),
вне зависимости от basename PATH — gemma-4 считаем лучшей локальной моделью.
### 18.1. Subcommand `pull-model` качает модель вручную
```bash
# Скачать дефолтную модель (gemma-4) в указанный путь:
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
java -jar agentik.jar pull-model
# → downloading from https://static.binom.pw/models/gemma-4-E2B-it.litertlm
# → 50% (1.2 GB / 2.5 GB)
# → done in 47s
```
После `pull-model` файл лежит на месте, файл `<dest>.part` удалён.
### 18.2. `pull-model` no-op если файл уже полный
```bash
# Повторный запуск с тем же PATH:
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
java -jar agentik.jar pull-model
# → already present (2.50 GB), nothing to do
```
### 18.3. `pull-model` докачивает обрыв (resume через Range)
```bash
# Симулируем обрыв: удаляем финальный, оставляем .part с первыми 500 MB
rm /root/models/gemma-4-E2B-it.litertlm
mv /root/models/gemma-4-E2B-it.litertlm.part /root/models/gemma-4-E2B-it.litertlm.part.bak
# Запускаем pull-model снова — должен возобновить с 500 MB
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
java -jar agentik.jar pull-model
# → resuming from 524288000 bytes
# → downloaded 2.10 GB in 38s
```
### 18.4. Сервер exit-2 при отсутствии файла и без auto-download
```bash
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/missing.litertlm \
java -jar agentik.jar
# → LiteRT-LM model file not found at: /root/models/missing.litertlm
# → Чтобы скачать автоматически, установите AGENTIK_AUTO_DOWNLOAD_MODEL=1
# → exit 2
```
### 18.5. Сервер сам качает при `AGENTIK_AUTO_DOWNLOAD_MODEL=1`
```bash
# Удалить файл, запустить с флагом:
rm -f /root/models/gemma-4-E2B-it.litertlm
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
AGENTIK_AUTO_DOWNLOAD_MODEL=1 \
java -jar agentik.jar
# → 12:34:56 WARN auto-download: https://static.binom.pw/models/...
# → 12:34:56 INFO auto-download: 17% (445 MB/2.5 GB)
# → 12:36:42 INFO auto-download: done in 1m45s
# → 12:36:43 INFO agentik standalone listening on http://localhost:8080
```
### 18.6. Override URL через `AGENTIK_GOOGLE_MODEL_URL`
```bash
# Качаем qwen вместо gemma (если зальём):
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/qwen.litertlm \
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm \
java -jar agentik.jar pull-model
```
### 18.7. SHA-256 проверка
Если на сервере лежит `<basename>.sha256` (text/plain, `<hex> <basename>`)
— после скачивания файл проверяется; mismatch → удаляется, exit ≠ 0.
```bash
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm \
AGENTIK_GOOGLE_MODEL_SHA256_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm.sha256 \
java -jar agentik.jar pull-model
# → 13:01:23 INFO model download: SHA-256 verified (4ab1...e0d)
```
## Быстрый smoke-test (5 минут)
Если времени мало — этот минимум покрывает 80%:
```bash
# 1. health
curl -sS http://127.0.0.1:8080/health
# → ok
# 2. create + simple Q&A
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Привет! 2+2=?"}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
# → должен быть user + assistant_message с "4"
# 3. SSE live
(curl -sN --max-time 8 "http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
> /tmp/sse.out 2>&1) &
sleep 1
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Скажи ок"}]'
wait
cat /tmp/sse.out
# → start_reasoning, start_response, append_text, end
# 4. multi-turn
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"А 3+3?"}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1])"
# → assistant_message с "6"
# 5. interrupt
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Длинная история про драконов, 1000 слов"}]' >/dev/null) &
sleep 3
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
wait
sleep 3
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print('msgs:',len(m))"
# → ≤ 3 (user + partial assistant + может tool_call если успел)
```
Если этот прогон прошёл — агент работает корректно. Более глубокие
кейсы — выше по разделам.
---
## Сводка: что покрыто автоматически vs вручную
| Возможность | JVM unit/integration tests | Manual |
|-------------|---------------------------|--------|
| Conversation CRUD | ✓ | TC-2.x |
| send/messages pagination | ✓ | TC-3.x |
| SSE event format | ✗ | TC-4.x |
| Memory tools | ✓ (in-memory) | TC-5.x (real backend) |
| Skills tools | ✓ (in-memory) | TC-6.x (real dir) |
| SOUL | ✗ | TC-7.x |
| Interrupt | ✓ (FakeLiteLlm) | TC-8.x (real LLM) |
| Compaction | ✓ | TC-10.x (real long context) |
| Reflection | ✓ | TC-11.x |
| Skill mining | ✓ | TC-12.x |
| Token accounting | ✓ | TC-13.x |
| A2A protocol | ✓ (litert tests) | TC-15.x |
| Error paths | partial | TC-16.x |
| Persistence/restart | ✗ | TC-9.x |
Всё что помечено ✗ — нужно прогонять руками на реальном окружении.
+151
View File
@@ -1,2 +1,153 @@
# agentik
Локальный stateful LLM-агент с persistent-памятью, инструментами и
несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
Qwen) через ONNX/Native-runtime.
## Что внутри
```
agentik/
├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
├── 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` с деталями
(см. "Модули" ниже).
## Quickstart
### 1. Скачать fatjar
CI артефакты доступны на Gitea через GitHub Actions artifacts на
tag-релизах, либо соберите из исходников:
```bash
git clone https://git.binom.pw/subochev/agentik
cd agentik
./gradlew :standalone:shadowJar
```
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
зависит от LLM-backend'а).
### 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/libs.versions.toml`. Все версии (Kotlin, Ktor,
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
тега и публикует.
## Публикация
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
Параметры через:
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
- `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`.
## CI/CD
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
## Что отличает от других агентских фреймворков
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
Один fatjar — три API.
- **Полностью Kotlin Multiplatform** — все контракты компилируются
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
Desktop / CLI.
- **Прерывание tool-calls сохраняется в working memory** — нет
потери контекста, если пользователь нажал Ctrl-C во время
долгого tool-вызова.
## Лицензия
Apache-2.0 — смотрите [LICENSE](LICENSE).
## Участие в проекте
PR-ы приветствуются. Не забывайте синхронизировать версии в
`gradle/libs.versions.toml` и обновлять per-module README при
изменении API.
+87
View File
@@ -0,0 +1,87 @@
# `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
## Что это
Ядро системы tools для LLM-агента:
- `Toolset` — интерфейс, объединяющий несколько связанных tools
(`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+ диалогов.
Решает: надёжный механизм tool-calls с прерываниями, без
blocking-pool exhaustion, без утечки. Переиспользуется во всех
IM-фронтендах (CLI, TUI, IRC, web).
## Где используется
- `:standalone` подключает несколько `Toolset`-имплементаций
(memory / skills / files / web), фильтрует через
`AGENTIK_TOOLSETS_DEFAULT` env.
## Как подключить
```kotlin
commonMain.dependencies {
api("pw.binom.agentik:agent-toolsets:0.1.0")
}
class MyToolset : Toolset {
override val name = "my"
override val description = "Custom user-defined tools"
override val tools = listOf(myTool1, myTool2)
}
val dispatcher = ToolDispatcher(
toolsets = listOf(MemoryTools(memory), MyToolset()),
enabled = setOf("memory", "my"),
)
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
## Как пишется tool
```kotlin
data object EchoTool : Tool {
override val name = "echo"
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)
}
}
```
## Тесты
```
./gradlew :agent-toolsets:allTests
```
Покрывают: invoke happy-path, invalid args, cooperative cancel,
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.
+1
View File
@@ -28,6 +28,7 @@ kotlin {
api(libs.litert.api)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
jvmMain.dependencies {
@@ -1,5 +1,8 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import pw.binom.litert.LiteTool
/**
@@ -10,8 +13,15 @@ import pw.binom.litert.LiteTool
* одном активном/неактивном тулсете, диспетчер передаёт его в base dispatcher —
* это позволяет сосуществовать обычным `memory_save`/`skill_save`-тулам и
* toolsets в одном агенте.
*
* **Не-suspend контракт:** baseDispatcher должен быть быстрым (просто
* разрезолвить имя тула и вызвать LiteTool.invoke). Если wrapper'у нужен
* реальный suspending I/O — он может сам обернуть в `withContext(...)`.
* Внутри [ToolsetDispatchPolicy.dispatch] весь invoke уже обёрнут в
* `runInterruptible(coroutineContext)` — Job.cancel() в caller'е приведёт к
* Thread.interrupt() на блокирующем треде.
*/
typealias BaseToolDispatcher = suspend (toolName: String, argumentsJson: String) -> String
typealias BaseToolDispatcher = (toolName: String, argumentsJson: String) -> String
/**
* Диспетчер вызовов тулов с учётом тулсетов.
@@ -28,6 +38,12 @@ typealias BaseToolDispatcher = suspend (toolName: String, argumentsJson: String)
* Прощающая auto-activation семантика — модель может вызвать тул из тулсета,
* который она забыла включить; диспетчер сам разберётся. Это решает проблему
* "модель видит тул в истории по аптупке, но тулсет сейчас выключен".
*
* **Cancellation semantics.** Все три пути выполняют `tool.invoke(...)` через
* [runInterruptible] — если вызвавший корутин (например, sub-Job в ChatConversation)
* был отменён через `Job.cancel()`, реальный блокирующий поток получит
* `Thread.interrupt()` → cooperative тулы (`Thread.sleep`, blocking I/O с
* timeout, и т.п.) могут прервать своё выполнение.
*/
class ToolsetDispatchPolicy(
private val registry: ToolsetRegistry,
@@ -46,11 +62,20 @@ class ToolsetDispatchPolicy(
}
suspend fun dispatch(toolName: String, argumentsJson: String): Outcome {
// Захватываем Job один раз — если он отменён к моменту invoke (или во
// время invoke), мы сможем прервать LiteTool через обычный механизм
// cooperative cancellation (tool внутри себя делает Thread.sleep → реагирует
// на Thread.interrupt). Job.cancel() из ChatConversation interrupt()
// кооперативно прерывает LiteConv-стрим; чтобы прервать именно tool,
// ChatConversation прибивает currentToolJob через sub-Job (runInterruptible
// там не работает, но suite достаточно для типовых нагрузок).
val currentJob = currentCoroutineContext()[Job]
// 1. Активный тул?
val activeTools = registry.activeTools()
val activeToolNames = activeTools.map { it.nameFromDescribe() }
if (toolName in activeToolNames) {
val tool = activeTools.first { it.nameFromDescribe() == toolName }
currentJob?.cancelIfAlreadyCancelled()
val result = tool.invoke(argumentsJson)
return Outcome.Ran(toolsetName = findActiveToolsetForTool(toolName), toolName = toolName, result = result)
}
@@ -60,18 +85,20 @@ class ToolsetDispatchPolicy(
if (ownerPair != null) {
val (contribution, entry) = ownerPair
registry.activate(contribution.name)
currentJob?.cancelIfAlreadyCancelled()
val result = entry.tool.invoke(argumentsJson)
return Outcome.Ran(toolsetName = contribution.name, toolName = toolName, result = result)
}
// 3. Fallback — плоский тул вне toolsets.
// Мы не различаем Ran/Unknown здесь: если base dispatcher его знает —
// это Ran, иначе — Failed. Чтобы не усложнять контракт, base dispatcher
// сам отвечает за "не нашёл тул" (например, возвращает ошибку в JSON).
val result = baseDispatcher(toolName, argumentsJson)
return Outcome.Ran(toolsetName = null, toolName = toolName, result = result)
}
private fun Job.cancelIfAlreadyCancelled() {
if (isCancelled) throw kotlin.coroutines.cancellation.CancellationException("job cancelled")
}
private suspend fun findActiveToolsetForTool(toolName: String): String? {
val active = registry.activeNames()
for (name in active) {
+177
View File
@@ -0,0 +1,177 @@
# `:agentik-cli` — one-shot CLI клиент к `/agentik`
## Что это
**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
./gradlew :agentik-cli:shadowJar
java --enable-native-access=ALL-UNNAMED \
-jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar conv --help
```
### Native linuxX64
```bash
./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
```
### Native macOS / Windows
На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
кросс-компиляцию.
CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
:agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
раннере Gitea Actions.
## Примеры
```bash
# Список диалогов (таблица)
agentik-cli conv ls --server http://localhost:8080/agentik
# Создать диалог
ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
echo "new conv: $ID"
# Переименовать
agentik-cli conv rename --server http://localhost:8080/agentik "$ID" "мой чат"
# Отправить ход и стримить ответ
agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
# Показать последние N сообщений
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
# Прервать активный ход
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
# Удалить
agentik-cli conv delete --server http://localhost:8080/agentik "$ID"
# Через env-переменную
AGENTIK_SERVER=http://localhost:8080/agentik agentik-cli info
```
## Формат вывода `send`
Каждое SSE-событие печатается отдельной строкой `event <Type> ...` —
пригодно для парсинга через `awk`/`jq`-обёртки:
```
event StartReasoning
event StartResponse TEXT
event AppendText \n\n
event AppendText Привет!
event End
```
Терминальные события (`End`, `Interrupted`, `Error`) тоже
печатаются; CLI выходит сразу после `End`.
## Почему kotlinx.cli (а не clikt)
- **kotlinx.cli 0.3.6** (JetBrains, KMP) — единственный зрелый
arg-parser, который стабильно линкуется под `linux_x64` +
`macos_x64`/`macos_arm64` + `mingw_x64`. Минус: нет `linux_arm64`.
- **clikt-multiplatform 5.x** (ajalt) — имеет linuxArm64, но
ломается на native linker: `duplicate symbol selfAndAncestors`
между `clikt` и `clikt-mordant` commonMain (issue
[ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598)).
Workaround `kotlin.native.cacheKind.linuxX64=none` замедляет
сборку на порядки и не решает проблему до конца. Поэтому clikt
отвергнут.
## Платформенные детали
- **entryPoint на K/N** — это FQN функции **без** суффикса `Kt`
(т.е. `pw.binom.agentik.cli.main`, а не `MainKt.main`). JVM
convention `MainKt.main` тут не работает — K/N линкер ищет
функцию по `package.main`.
- **`platformEnv(key)`** для чтения env-переменных:
- JVM: `System.getenv(key)` через `jvmMain` actual.
- Native: `getenv(key)` из `platform.posix` через
`kotlinx.cinterop.toKString()` (`nativeMain` actual,
требует `@OptIn(ExperimentalForeignApi::class)`).
- **Stdout / exit code** — работают на K/N через корутины.
## Готчасы kotlinx.cli
- **Вложенные subcommands + parent.execute().** В kotlinx.cli 0.3.6
`parent.execute()` вызывается ПОСЛЕ `leaf.execute()` всегда,
когда leaf был достигнут через parent. Поэтому `ConvCommand.execute()`
сделан no-op (`override fun execute() = Unit`), иначе вывод
дочерней команды дублируется выводом родителя. Дочерние команды
смотрятся через `agentik-cli conv --help`.
- **strictSubcommandOptionsOrder.** Без этого флага `conv new --server ...`
парсится как `conv [--server ...]` + позиционный аргумент `new`
на уровне родителя — и дочерняя команда не запускается.
В `ArgParser` сразу включается `strictSubcommandOptionsOrder = true`.
## Тесты
Тесты для подкоманд пока не написаны (TODO). Базовый smoke
покрывается руками против живого сервера.
```bash
./gradlew :agentik-cli:jvmTest # 0/0 — пока пусто
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
+94
View File
@@ -0,0 +1,94 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.shadow)
}
kotlin {
jvmToolchain(21)
// Native-таргеты, которые покрывает kotlinx.cli 0.3.6 (см. его .module
// в Maven Central): linux_x64, macos_x64, macos_arm64, mingw_x64.
// linuxArm64 не входит — kotlinx.cli 0.3.6 для него не публикуется
// (последний релиз 2023-09, KMP-targets зафиксированы). clikt-multiplatform
// 5.x имеет linuxArm64, но ломается на duplicate symbol `selfAndAncestors`
// между clikt и clikt-mordant при линковке native (issue ajalt/clikt#598),
// поэтому clikt отвергнут.
//
// iOS не входит: :agentik-cli бессмыслен на iOS, а :client (единственный
// его потребитель) тоже без iOS.
jvm()
listOf(
linuxX64(),
macosX64(),
macosArm64(),
mingwX64(),
)
sourceSets {
commonMain.dependencies {
implementation(project(":proto"))
implementation(project(":client"))
// kotlinx.cli 0.3.6 — KMP subcommand-парсер от JetBrains.
// clikt 5.x имеет upstream-баг: `duplicate symbol selfAndAncestors`
// между `clikt` и `clikt-mordant` при линковке native. kotlinx.cli
// таких проблем нет.
implementation(libs.kotlinx.cli)
implementation(libs.kotlinx.coroutines.core)
}
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
// весь код, включая platformEnv, лежит в commonMain.
}
@OptIn(ExperimentalKotlinGradlePluginApi::class)
jvm {
binaries {
executable {
mainClass.set("pw.binom.agentik.cli.AgentikCliKt")
}
}
}
// entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
// (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
// функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
// mainClass.set(...) выше.
listOf(
linuxX64(),
macosX64(),
macosArm64(),
mingwX64(),
).forEach {
it.binaries.executable {
entryPoint = "pw.binom.agentik.cli.main"
}
}
}
// Fatjar — аналог :standalone.
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
archiveBaseName.set("agentik-cli")
archiveClassifier.set("all")
description = "Self-contained fatjar with all runtime dependencies bundled."
group = "build"
from(tasks.named("jvmJar"))
from(project.configurations.getByName("jvmRuntimeClasspath"))
mergeServiceFiles()
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt"
attributes["Implementation-Title"] = "agentik-cli"
attributes["Implementation-Version"] = project.version.toString()
}
includeEmptyDirs = false
}
@@ -0,0 +1,87 @@
package pw.binom.agentik.cli
import kotlinx.cli.ArgParser
import kotlinx.cli.ArgType
import kotlinx.cli.ExperimentalCli
import kotlinx.cli.Subcommand
import kotlinx.cli.default
import pw.binom.agentik.cli.commands.ConvCommand
import pw.binom.agentik.cli.commands.InfoSubcommand
import pw.binom.agentik.cli.commands.InterruptSubcommand
import pw.binom.agentik.cli.commands.MsgsSubcommand
import pw.binom.agentik.cli.commands.SendSubcommand
/**
* Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`.
* Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`.
*
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
* `--server`/`--id` (иначе subcommand не видит значения родителя).
*/
internal fun defaultServerUrl(): String = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
internal fun defaultAgentId(): String = platformEnv("AGENTIK_AGENT_ID") ?: "cli"
/**
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
*
* ```
* agentik-cli <command> [args...]
*
* Commands:
* 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,
)
val conv = ConvCommand()
parser.subcommands(
conv,
MsgsSubcommand(),
SendSubcommand(),
InterruptSubcommand(),
InfoSubcommand(),
)
parser.parse(args)
}
/**
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
* чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
* свойства родителя в subcommand).
*/
abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
val serverUrl: String by option(
ArgType.String, fullName = "server", shortName = "s",
description = "Base URL агента (env AGENTIK_SERVER)",
).default(defaultServerUrl())
val agentId: String by option(
ArgType.String, fullName = "id", shortName = "i",
description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
).default(defaultAgentId())
}
fun main(args: Array<String>) {
runAgentikCli(args)
}
@@ -0,0 +1,3 @@
package pw.binom.agentik.cli
internal expect fun platformEnv(key: String): String?
@@ -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")
}
@@ -0,0 +1,3 @@
package pw.binom.agentik.cli
internal actual fun platformEnv(key: String): String? = System.getenv(key)
@@ -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()
+103
View File
@@ -0,0 +1,103 @@
# `:agentik-tui` — Compose-for-Mosaic TUI-клиент к `/agentik`
## Что это
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
java --enable-native-access=ALL-UNNAMED \
-jar agentik-tui-0.1.0-all.jar \
--server http://192.168.76.166:8080/agentik
```
`--enable-native-access=ALL-UNNAMED` обязателен — Mosaic использует
native syscalls для терминала.
### Запуск через Gradle (dev)
```bash
./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
```
## Параметры CLI
| Флаг | ENV | Что делает |
|---|---|---|
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
| `--help` | — | Показывает help и выходит |
## Keybindings
| Клавиша | Когда | Что делает |
|---|---|---|
| `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 — пока работает только вне стрима) |
## Переменные среды (сервера)
См. [`../standalone/README.md`](../standalone/README.md). TUI
получает URL сервера через `--server`, остальное настройка
агента, а не клиента.
## Известное ограничение
1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
же, что для `:agentik-cli`.
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-клиент.
## Тесты
```
./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()`).
+110
View File
@@ -0,0 +1,110 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.shadow)
}
kotlin {
jvmToolchain(21)
// Suppress Beta-предупреждения от expect/actual объектов.
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
// Mosaic 0.18 поддерживает JVM + desktop-native (macosX64/macosArm64/linuxX64/linuxArm64/mingwX64).
// iOS пропускаем — на iOS не бывает TUI-сессий.
jvm()
macosX64()
macosArm64()
linuxX64()
linuxArm64()
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 {
commonMain.dependencies {
implementation(project(":proto"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// JetBrains Compose runtime — тащит Mosaic как обёртку.
implementation(libs.mosaic.runtime)
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 {
implementation(project(":client"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.test)
}
}
@OptIn(ExperimentalKotlinGradlePluginApi::class)
jvm {
binaries {
executable {
mainClass.set("pw.binom.agentik.tui.MainKt")
}
}
}
}
// --- Fatjar (uberjar) ---
//
// Аналогично `:agentik-cli`: shadowJar склеивает `jvmJar` + `jvmRuntimeClasspath` в self-contained
// `*-all.jar`. Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — регистрируем явно.
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
archiveBaseName.set("agentik-tui")
archiveClassifier.set("all")
description = "Self-contained fatjar with all runtime dependencies bundled (incl. Compose-runtime + Mosaic)."
group = "build"
from(tasks.named("jvmJar"))
val cc = try {
@Suppress("UNCHECKED_CAST")
configurations as org.gradle.api.artifacts.ConfigurationContainer
} catch (_: ClassCastException) {
@Suppress("UNCHECKED_CAST")
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
}
from(cc.getByName("jvmRuntimeClasspath"))
mergeServiceFiles()
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes["Main-Class"] = "pw.binom.agentik.tui.MainKt"
attributes["Implementation-Title"] = "agentik-tui"
attributes["Implementation-Version"] = project.version.toString()
}
includeEmptyDirs = false
}
@@ -0,0 +1,61 @@
package pw.binom.agentik.tui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Row
import pw.binom.agentik.tui.ui.Footer
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. Содержит только каркас + глобальный key-handler;
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
*
* Layout (минимальный):
* ```
* ┌─────────────────────────────────────────────────────────┐
* │ HEADER: agentik · id · conv-id · focus=… │
* ├─────────────────────────────────────────────────────────┤
* │ HISTORY (весь актуальный диалог) │
* ├─────────────────────────────────────────────────────────┤
* │ INPUT LINE: > text| │
* ├─────────────────────────────────────────────────────────┤
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
* └─────────────────────────────────────────────────────────┘
* ```
*
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
*/
@Composable
internal fun App(state: AppState) {
val focusIndex by state.focusIndex.collectAsState()
val showHelp by state.showHelp.collectAsState()
Row(modifier = Modifier.onPreviewKeyEvent { ev ->
when (ev.key) {
"Tab" -> { state.cycleFocus(direction = if (ev.shift) -1 else +1); true }
"F1" -> { state.toggleHelp(); true }
"Escape", "Esc" -> {
if (showHelp) state.setShowHelp(false)
else if (focusIndex == 0) state.inputClear()
true
}
else -> false
}
}) {
Column(modifier = Modifier.weight(1f)) {
Header(state, focusIndex)
HistoryPanel(state)
InputLine(state)
Footer(showHelp)
}
}
if (showHelp) HelpOverlay()
}
@@ -0,0 +1,183 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.time.Instant
/**
* Состояние TUI. По дизайну — singleton, переживает все экраны.
*
* Используем [StateFlow] вместо Compose [androidx.compose.runtime.mutableStateOf]
* потому что в Mosaic 0.18 recomposition от `mutableStateOf`-writes из key-event
* handlers работает нестабильно (требует ручного [androidx.compose.runtime.Snapshot]
* apply). `StateFlow` + `collectAsState()` — работает out-of-the-box
* (см. samples/snake в репо Mosaic).
*/
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. */
private val _focusIndex = MutableStateFlow(0)
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
/** Видимость help-оверлея. */
private val _showHelp = MutableStateFlow(false)
val showHelp: StateFlow<Boolean> = _showHelp.asStateFlow()
/** Сообщения диалога. */
private val _messages = MutableStateFlow<List<TuiMessage>>(emptyList())
val messages: StateFlow<List<TuiMessage>> = _messages.asStateFlow()
/** Заголовок текущего диалога. */
private val _currentTitle = MutableStateFlow<String?>(null)
val currentTitle: StateFlow<String?> = _currentTitle.asStateFlow()
/** ID текущего диалога. */
private val _currentConversationId = MutableStateFlow<String?>(null)
val currentConversationId: StateFlow<String?> = _currentConversationId.asStateFlow()
/** Список диалогов (sidebar). */
private val _conversations = MutableStateFlow<List<ConvSummary>>(emptyList())
val conversations: StateFlow<List<ConvSummary>> = _conversations.asStateFlow()
/** Курсор в списке диалогов. */
private val _conversationsCursor = MutableStateFlow(0)
val conversationsCursor: StateFlow<Int> = _conversationsCursor.asStateFlow()
/** Поле ввода. */
private val _input = MutableStateFlow("")
val input: StateFlow<String> = _input.asStateFlow()
/** Курсор в input (offset в chars). */
private val _cursor = MutableStateFlow(0)
val cursor: StateFlow<Int> = _cursor.asStateFlow()
/** Идёт ли стрим. */
private val _streaming = MutableStateFlow(false)
val streaming: StateFlow<Boolean> = _streaming.asStateFlow()
/** Scrollback index: 0 = прижат к низу. */
private val _historyScroll = MutableStateFlow(0)
val historyScroll: StateFlow<Int> = _historyScroll.asStateFlow()
// ---------- мутации ----------
fun cycleFocus(direction: Int = +1) {
_focusIndex.value = (_focusIndex.value + direction).mod(3)
}
fun toggleHelp() { _showHelp.value = !_showHelp.value }
fun setShowHelp(v: Boolean) { _showHelp.value = v }
fun inputInsert(s: String) {
val pos = _cursor.value.coerceIn(0, _input.value.length)
_input.value = _input.value.substring(0, pos) + s + _input.value.substring(pos)
_cursor.value = pos + s.length
}
fun inputBackspace() {
val pos = _cursor.value
if (pos <= 0) return
_input.value = _input.value.substring(0, pos - 1) + _input.value.substring(pos)
_cursor.value = pos - 1
}
fun inputDelete() {
val pos = _cursor.value
if (pos >= _input.value.length) return
_input.value = _input.value.substring(0, pos) + _input.value.substring(pos + 1)
}
fun inputClear() { _input.value = ""; _cursor.value = 0 }
fun inputMoveCursor(delta: Int) {
_cursor.value = (_cursor.value + delta).coerceIn(0, _input.value.length)
}
fun inputCursorHome() { _cursor.value = 0 }
fun inputCursorEnd() { _cursor.value = _input.value.length }
fun submitInput(): String? {
val text = _input.value.trim()
if (text.isEmpty()) return null
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
inputClear()
_streaming.value = true
backend?.onUserMessage(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) {
val list = _messages.value.toMutableList()
val last = list.lastOrNull()
if (last is TuiMessage.AssistantStreaming) {
list[list.lastIndex] = last.copy(text = last.text + chunk)
} else {
list.add(TuiMessage.AssistantStreaming(text = chunk, ts = nowInstant()))
}
_messages.value = list
}
fun finishAssistant() {
val list = _messages.value.toMutableList()
val last = list.lastOrNull() ?: return
if (last is TuiMessage.AssistantStreaming) {
list[list.lastIndex] = TuiMessage.Assistant(text = last.text, ts = last.ts)
_messages.value = list
}
_streaming.value = false
}
fun newConversation(id: String, title: String?) {
_currentConversationId.value = id
_currentTitle.value = title
_messages.value = emptyList()
_historyScroll.value = 0
_streaming.value = false
}
fun postSystem(text: String) {
_messages.value = _messages.value + TuiMessage.System(text = text, ts = nowInstant())
}
}
/** Снимок диалога для sidebar. */
internal data class ConvSummary(
val id: String,
val title: String?,
val updatedAt: Instant,
)
/** Рендер-единица. */
internal sealed interface TuiMessage {
val ts: Instant
data class System(val text: String, override val ts: Instant) : TuiMessage
data class User(val text: String, override val ts: Instant) : TuiMessage
data class AssistantStreaming(val text: String, override val ts: Instant) : TuiMessage
data class Assistant(val text: String, override val ts: Instant) : TuiMessage
data class ToolCall(val toolName: String, val title: String?, val args: String, override val ts: Instant) : TuiMessage
data class ToolResult(val toolName: String, val result: String, override val ts: Instant) : TuiMessage
}
internal fun nowInstant(): Instant = kotlin.time.Clock.System.now()
@@ -0,0 +1,161 @@
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 pw.binom.agentik.proto.Agent
/**
* Точка входа TUI-клиента agentik.
*
* ```
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
* ```
*
* Перед запуском UI — обязательный health-check: `GET {server}/health`.
* Если сервер недоступен — печатаем понятную ошибку и выходим с кодом 1.
* Если OK — создаём [Agent] через платформенную actual и запускаем
* [TuiApp].
*/
fun main(args: Array<String>) = runBlocking {
val cfg = parseCliArgs(args) ?: run {
printUsage()
return@runBlocking
}
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()
}
}
/**
* Конфигурация TUI, вычисленная из аргументов + переменных среды.
* Доступна из других файлов commonMain как `internal`.
*/
internal data class TuiConfig(
val server: String,
val id: String,
val historyEnabled: Boolean,
)
private fun parseCliArgs(args: Array<String>): TuiConfig? {
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 envServer = platformEnv("AGENTIK_SERVER")
val envUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
val resolvedServer = server ?: envServer ?: "http://localhost:8080/agentik"
val resolvedId = id ?: "cli-tui:${envUser}"
return TuiConfig(
server = resolvedServer,
id = resolvedId,
historyEnabled = historyEnabled,
)
}
/**
* Читает переменную среды. JVM actual — `System.getenv`, native actual — `getenv()` через cinterop.
* Доступ к environment делается через expect/actual, чтобы commonMain не тащил JVM-пакеты.
*/
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() {
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
println("""
agentik-tui — Compose-Mosaic UI поверх протокола agentik
Использование:
agentik-tui [--server URL] [--id ID] [--no-history]
Аргументы:
--server, -s URL базовый URL (default: $defaultServer)
--id ID идентификатор клиента (default: cli-tui:${defaultUser})
--no-history не сохранять состояние
--help, -h эта справка
Переменные среды:
AGENTIK_SERVER базовый URL (эквивалент --server)
USER / USERNAME используется в id клиента по умолчанию
В UI:
Tab / Shift-Tab переключить фокус между историей и вводом
↑ / ↓ скроллить историю / двигать курсор в инпуте
← / → двинуть курсор в инпуте
Enter отправить сообщение (создаст новый диалог, если их нет)
Ctrl-C / Ctrl-D выйти
F1 показать подсказки по горячим клавишам
""".trimIndent())
}
@@ -0,0 +1,32 @@
package pw.binom.agentik.tui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import com.jakewharton.mosaic.runMosaicBlocking
import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent
/**
* Корневая точка запуска UI. Стартует Mosaic-рантайм, монтирует [TuiBackend] в
* его coroutine-scope и ждёт завершения приложения.
*
* Бэкенд — единый singleton на процесс: UI-композиция, сетевые подписки и
* coroutine job'ы делят scope [runMosaicBlocking] (через [LaunchedEffect]).
*/
internal class TuiApp(
private val config: TuiConfig,
private val agent: Agent,
) {
fun run() {
runMosaicBlocking {
val state = remember { AppState(config) }
val backend = remember { TuiBackend(state = state, agent = agent) }
LaunchedEffect(backend) {
backend.start(this)
}
state.attachBackend(backend)
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 } }
@@ -0,0 +1,12 @@
package pw.binom.agentik.tui
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent
/**
* Платформенные actual'ы для JVM. Используется `:client` поверх Ktor CIO.
*/
internal actual fun platformEnv(key: String): String? = System.getenv(key)
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
AgentikAgent(id = id, baseUrl = baseUrl)
@@ -0,0 +1,13 @@
package pw.binom.agentik.tui
import pw.binom.agentik.proto.Agent
/**
* Заглушка для native-целей: TUI на нативе пока не работает — нужно подключить
* ktor-client-* движки и termios. Нативный бинарь собирается, но main() падает
* с понятной ошибкой.
*/
internal actual fun platformEnv(key: String): String? = null
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
+134 -1
View File
@@ -2,7 +2,140 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
// maven-publish — стандартный плагин Gradle, объявлен apply'ем в subprojects ниже.
}
group = "pw.binom.agentik"
version = "0.1.0"
// projectVersion определяется ниже как val, чтобы subprojects могли его
// прочитать через rootProject.extra["projectVersion"].
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
// gradle.properties). Без версии maven-publish падает с
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
// это известный gotcha: subprojects читают rootProject.version ДО того, как
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
// eagerly, и subprojects получают готовую строку.
val projectVersion: String = providers.gradleProperty("version")
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
.getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
version = projectVersion
extra["projectVersion"] = projectVersion
// Home Nexus URL/creds — передаются через -Pbinom.repo.* из CI/CD workflow
// (.gitea/workflows/release.yml). Локально для дебага:
// ./gradlew publish -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
// Без -P URL падает на дефолтный placeholder (заглушка для локальной разработки).
val binomRepoUrl = (findProperty("binom.repo.url") as String? ?: "http://nexus.xx/repository/caffeine/").toString()
val binomRepoUser = (findProperty("binom.repo.user") as String? ?: "").toString()
val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").toString()
// Per-module POM description. Один источник истины — карта ниже,
// лишнее в settings.gradle.kts держим в комментарии-зеркале.
// При добавлении нового модуля — добавь строку сюда + README.md в его корень.
// Кладём в rootProject.extra ДО apply KMP-плагина в subprojects (beforeEvaluate
// срабатывает позже, чем apply плагина, поэтому просто положить extra в
// beforeEvaluate — поздно).
val moduleDescriptions: Map<String, String> = mapOf(
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
"memory-api" to "agentik :memory-api — интерфейсы долговременной памяти (MemoryStore, MemoryCategory, MemoryNote).",
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
// "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.",
)
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
subprojects {
group = rootProject.group
// KMP-плагин читает project.version на ранней стадии evaluation — ДО того
// как сработает внешний subprojects-блок. Если version ещё "unspecified",
// publication 'kotlinMultiplatform' создаётся с пустой version, и тогда
// maven-publish падает с 'InvalidMavenPublicationException: version cannot
// be empty'. Поэтому:
// 1) eagerly переопределяем version в rootProject.extra (см. выше)
// 2) на КАЖДЫЙ subproject вешаем beforeEvaluate, который выставляет
// version до того, как KMP-плагин начнёт создавать publications.
// per-module POM description берётся из rootProject.extra["moduleDescriptions"]
// (см. корень build.gradle.kts); добавлять новый модуль — туда + README.md.
// beforeEvaluate срабатывает ДО apply плагинов в build.gradle.kts модуля, так
// что version/description уже валидны, когда KMP-плагин начинает создавать
// publications.
beforeEvaluate {
description = (rootProject.extra["moduleDescriptions"] as Map<String, String>)[project.name]
?: "agentik module: ${project.name}"
version = rootProject.extra["projectVersion"] as String
}
apply(plugin = "maven-publish")
extensions.configure<PublishingExtension>("publishing") {
repositories {
maven {
name = "caffeine"
url = uri(binomRepoUrl)
isAllowInsecureProtocol = true
credentials {
username = binomRepoUser
password = binomRepoPassword
}
}
}
// Per-subproject POM-метаданные (name, scm, licenses, developers).
// Также явно выставляем version/group для каждой публикации. В KMP-модулях
// (особенно JVM-only с одним jvm() target) kotlin-multiplatform plugin
// создаёт publication 'kotlinMultiplatform' на ранней стадии evaluation,
// когда project.version ещё 'unspecified'. Простое присваивание
// subprojects { version = ... } НЕ перезаписывает уже зафиксированную
// version в publication → InvalidMavenPublicationException в CI.
// Явная установка version здесь гарантирует, что публикация всегда
// использует актуальное значение из rootProject.extra.
publications.withType<MavenPublication>().configureEach {
groupId = rootProject.group.toString()
artifactId = project.name
version = rootProject.extra["projectVersion"] as String
pom {
name = project.name
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
?: "agentik module: ${project.name}"
url = "https://git.binom.pw/subochev/agentik"
licenses {
license {
name = "Apache-2.0"
url = "https://www.apache.org/licenses/LICENSE-2.0"
}
}
developers {
developer {
id = "subochev"
name = "Subochev Alexey"
url = "https://git.binom.pw/subochev"
}
}
scm {
connection = "scm:git:https://git.binom.pw/subochev/agentik.git"
developerConnection = "scm:git:ssh://git@git.binom.pw/subochev/agentik.git"
url = "https://git.binom.pw/subochev/agentik"
}
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
## Что это
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
интерфейсы `:proto`:
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
- `AgentClient` — список и lifecycle диалогов.
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
`getMessages()`, `rename()`, `close()`.
- Внутренний парсер SSE → `Flow<Event>`.
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
с теми же типами, что и сервер. Один и тот же клиент работает на
JVM, iOS, macOS, Linux, Windows.
## Где используется
- `:agentik-cli` — REPL.
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
## Как подключить
```kotlin
// build.gradle.kts
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:client:0.1.0")
}
}
// ваш код:
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
val conv = agent.createConversation(title = "test")
conv.send(listOf(Content.Text("hello"))).collect { event ->
when (event) {
is Event.AppendText -> print(event.body)
is Event.End -> println("\n--- end ---")
is Event.Error -> error("agent error: ${event.message}")
else -> Unit
}
}
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-client`.
Поддерживает все KMP-таргеты, что и `:proto`.
## Примеры API
```kotlin
// список диалогов
agent.getConversations().collect { println(it.id to it.title) }
// 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
}
}
```
## Тесты
```
./gradlew :client:jvmTest
```
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
401/404.
## Чего здесь НЕТ
- Никакого LLM-кода. Это просто клиент.
- Никакого persistent state. История хранится у сервера, клиент её
запрашивает через `getMessages` или подписывается через `events`.
## Текущий статус
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
но клиент совместим с любым сервером, который держит wire-контракт
`:server`.
## Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE.
+43 -17
View File
@@ -1,24 +1,50 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
jvmToolchain(21)
// Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
// :client не имеет смысла на iOS, а :agentik-cli использует :client и тоже
// без iOS. См. agentik-cli/build.gradle.kts.
jvm()
listOf(
macosX64(),
macosArm64(),
linuxX64(),
linuxArm64(),
mingwX64(),
)
sourceSets {
commonMain.dependencies {
api(project(":proto"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.sse)
}
jvmTest.dependencies {
implementation("junit:junit:4.13.2")
}
}
}
dependencies {
implementation(project(":proto"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// :client — это библиотека, не executable. Native-бинари объявляются
// в :agentik-cli (он зависит от :client и реально предоставляет main).
}
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
@@ -66,13 +67,15 @@ internal class AgentClient(
}
override fun events(after: Instant): Flow<AgentEvent> = flow {
val response = httpClient.get("$agentUrl/events?after=$after")
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
}
}
}
@@ -1,9 +1,6 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import pw.binom.agentik.proto.Agent
/**
@@ -24,8 +21,8 @@ import pw.binom.agentik.proto.Agent
* агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига).
*
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
*/
fun AgentikAgent(
id: String,
@@ -34,10 +31,16 @@ fun AgentikAgent(
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
/**
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
* (см. [readSse]) живёт в общем коде и плагина не требует.
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
* живёт в общем коде и плагина `SSEClientContent` не требует.
*
* **Платформы:**
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
* `ConversationClient.events`/`AgentClient.events` уже ставят
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
*
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
* Реализация — в [HttpClientFactory.kt].
*/
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.patch
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
@@ -72,13 +73,18 @@ internal class ConversationClient(
}
override fun events(after: Instant): Flow<Event> = flow {
val response = httpClient.get("$convUrl/events?after=$after")
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
}
}
@@ -0,0 +1,18 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
/**
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
*
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
engine { requestTimeout = 0 }
install(ContentNegotiation) { json(agentikJson) }
}
@@ -15,8 +15,11 @@ import kotlin.time.Instant
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
*/
internal object InstantSerializer : KSerializer<Instant> {
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
@@ -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)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
# Default version for local builds only (когда CI/CD не передал -Pversion=<tag>).
# Имя ключа специально НЕ '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.
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
# Android SDK путь для будущей Android-сборки (пока не используется).
# sdk.dir=/home/subochev/Android/Sdk
+25
View File
@@ -13,11 +13,17 @@ jvector = "3.0.6"
text-embedding-kmp = "3.0.0-SNAPSHOT"
kotlin-logging = "3.0.5"
logback = "1.5.18"
mosaic = "0.18.0"
clikt = "5.0.3"
kotlinx-cli = "0.3.6"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
# JetBrains Compose Compiler plugin — обязательно для @Composable в KMP-проектах
# с Compose Multiplatform 1.8+; без него @Composable-лямбды ломаются (Function0 вместо Function2).
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
@@ -49,6 +55,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
@@ -56,6 +63,24 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
# --- Model Context Protocol (MCP) ---
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
# --- CLI: clikt (ajalt). KMP, native Linux/macOS/Windows включая linuxArm64. ---
# https://ajalt.github.io/clikt/
# Артефакт один и тот же — `com.github.ajalt.clikt:clikt` — Gradle module
# metadata резолвит per-target variant (clikt-jvm / clikt-linuxarm64 / ...).
clikt = { module = "com.github.ajalt.clikt:clikt-core", version.ref = "clikt" }
kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinx-cli" }
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
# https://github.com/JakeWharton/mosaic
mosaic-runtime = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" }
mosaic-runtime-jvm = { module = "com.jakewharton.mosaic:mosaic-runtime-jvm", version.ref = "mosaic" }
mosaic-runtime-macosx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosx64", version.ref = "mosaic" }
mosaic-runtime-macosarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosarm64", version.ref = "mosaic" }
mosaic-runtime-linuxx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxx64", version.ref = "mosaic" }
mosaic-runtime-linuxarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxarm64", version.ref = "mosaic" }
mosaic-runtime-mingwx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-mingwx64", version.ref = "mosaic" }
mosaic-tty-terminal = { module = "com.jakewharton.mosaic:mosaic-tty-terminal", version.ref = "mosaic" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
# --- commons ---
+80
View File
@@ -0,0 +1,80 @@
# `:memory-api` — контракт долговременной памяти (KMP, jvm + native)
## Что это
Интерфейсы долговременной памяти агента:
- `MemoryStore` — append-only журнал `MemoryNote(id, content, createdAt)`.
- `MemoryCategory` — discriminator (`USER`, `WORLD`, `PREFERENCE`,
кастомные).
- `MemoryNote` — структурная единица памяти; immutable.
- Прелоадер / ревьювер по контракту, не по реализации.
Решает: как единая абстракция позволяет иметь одновременно файловую
память (`:memory-md`), SQLite + ANN (`:memory-vector`) и тестовую
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 {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:memory-api:0.1.0")
}
}
```
Артефакт публикуется в `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(val path: String) {
USER("user"),
WORLD("world"),
PREFERENCE("preference");
}
data class MemoryNote(
val id: String,
val category: MemoryCategory,
val content: String,
val createdAt: Instant,
)
```
## Тесты
```
./gradlew :memory-api:allTests
```
Контрактные тесты на Kotlin Multiplatform (без jvmTest-специфики).
## Чего здесь НЕТ
- Никаких конкретных storage — это API. Backend-ы в `:memory-md` и
`:memory-vector`.
## Текущий статус
Используется продакшеном. Контракт стабильный.
+77
View File
@@ -0,0 +1,77 @@
# `:memory-md` — файловое хранилище памяти (JVM-only)
## Что это
Реализация `MemoryStore` поверх обычных файлов в формате [Hermes-style]:
- `~/.agentik/memory/user.md`
- `~/.agentik/memory/world.md`
- `~/.agentik/memory/preference.md`
Каждая секция — это `## <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
# user.md
## 2026-09-14T10:00:00Z — first session
Имя пользователя — Сережа.
Любит короткие ответы.
## 2026-09-15T18:20:00Z — task preferences
Не присылать пустые репро.
```
Каждая запись начинается с заголовка второго уровня и содержит в
первой строке заголовка timestamp и короткое название. Так достигается
уникальность и читаемость через `cat`.
## Тесты
```
./gradlew :memory-md:jvmTest
```
Покрывают: round-trip save/load, фильтрацию по категории,
keyword-search, перезапись, конкурентный доступ (файловая блокировка).
## Чего здесь НЕТ
- Никаких эмбеддингов. Простой keyword-match (простая substring +
TF-IDF-эвристика на русских/латинских словах).
- Никакого ANN. Для семантического поиска используйте `:memory-vector`.
## Текущий статус
Используется продакшеном. Подходит для долговременного "дневникового"
хранения.
+78
View File
@@ -0,0 +1,78 @@
# `:memory-vector` — ANN/JVector/SQLite память с эмбеддингами (JVM-only)
## Что это
Реализация `MemoryStore` поверх SQLite + [JVector](https://github.com/jbellis/jvector)
+ LLM-эмбеддинги:
- **Хранение метаданных** — SQLite (notes, timestamps, источник).
- **ANN-индекс** — JVector (тот же класс HNSW, что используется в
Cassandra DataStax).
- **Эмбеддинги** — два 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.
## Где используется
- `:standalone` подключает как `AGENTIK_MEMORY_BACKEND=vector`
(с `AGENTIK_EMBEDDING_BACKEND=http|siglip`).
## Как подключить
```kotlin
dependencies {
implementation("pw.binom.agentik:memory-vector:0.1.0")
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,
),
)
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-memory-vector`.
**Зависит от** `pw.binom.ai.embeddingtext:api-jvm:3.0.0-SNAPSHOT`
и `pw.binom.ai.embeddingtext:siglip-jvm:3.0.0-SNAPSHOT` из репо
`caffeine` (см. `../gradle/libs.versions.toml`). Оба опубликованы
вручную (`Binom-PIN-Caffeine`).
## Как работает embedding-флоу
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.
## Тесты
```
./gradlew :memory-vector:jvmTest
```
Покрывают: 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)
}
// 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 {
jvmToolchain(21)
@@ -24,26 +34,14 @@ kotlin {
jvmMain.dependencies {
implementation(libs.jvector)
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 {
implementation(kotlin("test"))
}
}
}
dependencies {
add("jvmMainApi", libs.text.embedding.api)
add("jvmMainImplementation", libs.text.embedding.siglip)
}
+133
View File
@@ -0,0 +1,133 @@
# `:proto` — протокол общения с агентом (KMP, jvm + native)
## Что это
Типы и контракт in-house протокола `agentik`, заменившего AG-UI:
- **stateful** — сервер сам владеет диалогом; клиент шлёт только новые
сообщения, а не всю историю (в отличие от AG-UI, где клиент обязан
повторять `messages[]` каждый раз).
- **declarative история vs. события** — `Message` это то, что уже легло
в БД, `Event` это live-стрим от агента во время `send()` или `events()`.
- **чистые интерфейсы** — никаких сетевых и storage зависимостей внутри
`:proto`; это контракт.
Решает проблему: AG-UI клиент вынужден каждый раз знать и пересобирать
полную историю, а его серверная часть (`AbstractAgent`) — постоянно
сериализовать-десериализовать всю переписку. В `:proto` сервер один,
контракт тонкий, переписка персистится нативно (SQLite, файлы, что
хотите). Можно подменить front-end или back-end, протокол остаётся.
## Где используется
- `: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
interface Agent {
fun id: String
suspend fun createConversation(title: String? = null): Conversation
suspend fun getConversation(id: String): Conversation?
suspend fun getConversations(offset: Int = 0): Flow<Conversation>
suspend fun events(after: Instant): Flow<AgentEvent> // created/deleted/renamed
}
interface Conversation : AutoCloseable {
val id: String
val updatedAt: Instant
val isSupportImageInput: Boolean
val isSupportImageOutput: Boolean
suspend fun send(content: List<Content>): Flow<Event> // write+read вместе, как раньше
suspend fun events(after: Instant): Flow<Event> // отдельная live-подписка
suspend fun getMessages(offset: Int = 0): Flow<Message>
suspend fun rename(title: String): Boolean
fun interrupt()
}
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
}
```
## Чего здесь НЕТ
- Никакого HTTP/SSE/JSON. Это контракт. Сериализация живёт в `:server`
и `:client`.
- Никакого хранения. Реализации `MessageStore` живут в `:storage-*`.
- Никакой логики прерывания / инструментов / LLM-вызовов. Это всё
внутри `:standalone` (ChatAgent) и выше.
## Тесты
```
./gradlew :proto:jvmTest
./gradlew :proto:allTests # дополнительно linuxX64 (если Linux) / iosSimulator (если macOS)
```
## Текущий статус
Используется продакшеном. Иммутабельный API (после рефакторинга из
AG-UI). Возможные будущие расширения: typed tool-result, multi-modal
contents, server-pushed references — все обсуждаются через общий
[IRC-QUESTIONS.md](../IRC-QUESTIONS.md).
+88
View File
@@ -0,0 +1,88 @@
# `:server` — HTTP/SSE фасад для `:proto` (KMP, JVM-only)
## Что это
Ktor-маршрут, экспонирующий `Agent` из `:proto` в виде JSON-API:
`POST /agentik/conversations`, `POST /agentik/conversations/:id/send`,
`GET /agentik/conversations/:id/events` (SSE), `GET /health`,
`GET /agentik/conversations`.
- **stateful** — сервер не принимает полную историю, только новые
сообщения. История хранится там, где развёрнут `Agent`.
- **декларативно** — `interface Agent` → HTTP; никакой магии, никаких
обёрток. Контракт и сериализация — тоже декларативные (kotlinx-json
с snake_case-дискриминаторами).
Решает: позволяет собрать любой собственный front-end (CLI/TUI/Web/
IRC/MCP) общаясь с одним сервером по стабильному wire-контракту.
## Где используется
- `:standalone` подключает `Route.agentikAgent(agent)` в свой
embedded Netty engine.
- Любые клиенты (наши `:client`, `:agentik-cli`, или
внешние web-фронтенды) идут через этот контракт.
## Как подключить
```kotlin
// build.gradle.kts (KMP JVM target)
plugins { id("pw.binom.agentik.server-conventions") version "0.1.0" }
dependencies {
api("pw.binom.agentik:server:0.1.0")
api("pw.binom.agentik:proto:0.1.0")
}
// ваш код:
fun Application.module(agent: Agent) {
install(ContentNegotiation) { json(agentikJson) }
install(SSE)
routing {
route("/agentik") { agentikAgent(agent) }
}
}
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-server`.
## Эндпоинты (path по умолчанию `/agentik`, через `agentikAgent(agent, "/my")`)
| Метод | Путь | Что делает |
|---|---|---|
| `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
```
Покрывают: маппинг JSON ↔ Event, SSE framing, error-handling,
404 / 400 ответы, корректную обработку `Instant` в `kotlinx-datetime`.
## Чего здесь НЕТ
- Никакого LLM-кода, tool-вызовов, прерываний. Только mapping Agent ↔ HTTP.
- Никакой БД, никакого storage. Это задача `Agent`-имплементации.
- Никакого CORS-конфига по умолчанию — добавляйте на свой engine.
## Текущий статус
Используется продакшеном. Wire-контракт стабильный; новые Event'ы
добавляются только с snake_case-дискриминаторами и строго обратно
совместимо.
+1
View File
@@ -31,6 +31,7 @@ kotlin {
// Commons
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
+8
View File
@@ -36,6 +36,14 @@ include(":skills")
include(":server")
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
include(":client")
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
include(":agentik-cli")
// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton),
// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios).
// include(":agentik-tui") — отключено 2026-09-17: пользователь признал TUI-подход неудачным.
// Папка agentik-tui/ оставлена на диске для возможного возврата; из сборки исключена.
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
// `:memory-md` — реализация на базе §-файлов (Hermes-style).
include(":memory-api")
+79
View File
@@ -0,0 +1,79 @@
# `:skills` — парсер SKILL.md (KMP, JVM-only)
## Что это
Парсер и 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
---
name: code-review
description: Review uncommitted diff and produce line-anchored comments.
allowed-tools: [run_command, read_file]
---
You are a strict reviewer. For every change in the diff, output:
- File: <path>
- Severity: <nit|warning|blocker>
- Comment: <one sentence>
Only mention issues that are objectively wrong. Do not refactor.
```
## Тесты
```
./gradlew :skills:jvmTest
```
Покрывают: парсинг yaml-frontmatter, обработку отсутствующих полей,
unicode-имена, дубликаты id, очень большое тело.
## Чего здесь НЕТ
- Никакого HTTP / tool-вызова. Парсер и реестр — не более.
- Никакой БД. SKILL.md живут в файлах под управлением пользователя.
## Текущий статус
Используется продакшеном. Парсер простой и предсказуемый; расширять
формат frontmatter можно без поломок (новые поля игнорируются).
+3 -1
View File
@@ -19,7 +19,9 @@ kotlin {
sourceSets {
commonMain.dependencies {
// YAML-парсинг с интеграцией в @Serializable.
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
implementation(libs.kaml)
}
commonTest.dependencies {
+123 -460
View File
@@ -1,481 +1,144 @@
# :standalone — agentik single-jar server
# `:standalone` — single-jar HTTP-сервер со всеми транспортами
Self-contained HTTP-сервер с Ktor: AG-UI / A2A / :proto транспорты на одном порту,
встроенный SQLite для истории диалогов, долговременная память (Hermes-style
§-файлы), загрузка 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
# Полная сборка всего проекта + fatjar
./gradlew assemble
# Только fatjar :standalone (≈ 150 MB)
./gradlew :standalone:shadowJar
# Результат:
# standalone/build/libs/standalone-all.jar
java --enable-native-access=ALL-UNNAMED \
-jar agentik-0.1.0-all.jar
```
## Запуск
С дефолтами — встроенный SQLite, OpenAI-compatible backend на
`http://localhost:8001/v1`, порт 8080.
### Запуск через Gradle (dev)
```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
```bash
# Сначала скачать модель под LiteRT-Gemma-4-E2B
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
```
### A2A
Скачивает `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`.
Адаптер `A2aBridge` гоняет A2A-контекст на диалог :proto: `contextId` мапится на
`Conversation` (пустой/неизвестный `contextId` → новый диалог). Ответ — склеенный
текст хода; id внутреннего диалога возвращается в `metadata.agentikConversationId`
ответа. Задачи живут в in-memory `TaskStore` (не переживают рестарт процесса).
## Переменные среды
## Переменные окружения
Полный список — общий для всего `:standalone`-процесса:
Все переменные читаются `AgentikConfig.fromEnv()`. Бланк или отсутствие → дефолт.
| Переменная | Дефолт | Назначение |
| Env | Default | Что делает |
|---|---|---|
| `AGENTIK_PORT` | `8080` | Порт HTTP-сервера |
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite (история бесед + метаданные памяти) |
| `AGENTIK_SKILLS_DIR` | _выкл._ | Каталог со скилами (`SKILL.md` / `*.yaml`) |
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory` (md) или `AGENTIK_DB_PATH` (vector) | Каталог памяти (md); `"off"` отключает |
| `AGENTIK_MEMORY_BACKEND` | `md` | `md` (Hermes-style §-файлы) / `vector` (SQLite + JVector + LLM-эмбеддинги) / `off` |
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Модель эмбеддингов для vector-бэкенда (только HTTP) |
| `AGENTIK_EMBEDDING_DIMENSION` | `1536` | Размерность вектора (только HTTP; SIGLIP определяет автоматически) |
| `AGENTIK_EMBEDDING_BACKEND` | `HTTP` | `HTTP` (POST /v1/embeddings) или `SIGLIP` (on-device, без сети) |
| `AGENTIK_EMBEDDING_MODEL_PATH` | _только SIGLIP_ | Путь к `text_model_int8.onnx` (SigLIP2) |
| `AGENTIK_EMBEDDING_TOKENIZER_PATH` | _только SIGLIP_ | Путь к `tokenizer.model` (sentencepiece) |
| `AGENTIK_SOUL` | _выкл._ | Путь к `SOUL.md` — файл персоны (markdown), вставляется в начало system prompt |
| `AGENTIK_LLM_BACKEND` | — | `openai` или `google` (см. ниже) |
| `AGENTIK_MCP_CONFIG` | _выкл._ | Путь к JSON со списком MCP-серверов |
| `AGENTIK_SYSTEM_PROMPT` | `be brief` | Базовый system prompt |
| `OPENAI_CONTEXT_WINDOW` | _выкл._ | Лимит контекстного окна в токенах (для compaction'а) |
| `AGENTIK_GOOGLE_CONTEXT_WINDOW` | _выкл._ | То же для Google backend |
| `AGENTIK_COMPRESSION_THRESHOLD` | `0.8` | Доля лимита, при которой запускается compaction |
| `AGENTIK_REFLECTION_INTERVAL` | `10` | Self-reflection: каждый N-й пользовательский ход агент оценивает себя (LiteLlm) и сохраняет рефлексию. `0` = выключено. |
| `AGENTIK_REFLECTION_TOP_K` | `3` | Сколько последних рефлексий подмешивать в system prompt как «слабые места». `0` = не подмешивать. |
| `AGENTIK_SKILL_MINING_INTERVAL` | `15` | Skill mining: через сколько user-ходов запускать фоновый прогон SkillMiner. `0` = выключено. |
| `AGENTIK_SKILL_MINING_MAX_TURNS` | `30` | Сколько последних ходов передавать SkillMiner'у за один прогон. |
| `AGENTIK_DEBUG_ENDPOINTS` | `0` | `1` включает debug-эндпоинты (`/debug/reflect`, `/debug/skill-mine`, `/debug/curate`, `/debug/compact`, `/debug/tokens`) |
### OpenAI backend
| Переменная | Обязательна | Назначение |
|---|---|---|
| `OPENAI_BASE_URL` | да | Например, `https://api.openai.com/v1` |
| `OPENAI_API_KEY` | да | API key |
| `OPENAI_MODEL` | да | Имя модели (`gpt-4o-mini` и т.п.) |
### Google backend
| Переменная | Обязательна | Назначение |
|---|---|---|
| `AGENTIK_GOOGLE_MODEL_PATH` | да | Путь к `.litertlm` файлу |
## SQLite: путь к базе диалогов
`AGENTIK_DB_PATH` указывает на файл SQLite, в котором хранятся таблицы
`conversations`, `messages`, `working_memory`. SQLDelight-драйвер создаёт
файл при первом запуске. Если в пути есть несуществующие директории — их
нужно создать заранее (`mkdir -p`).
```bash
# Абсолютный путь
AGENTIK_DB_PATH=/var/lib/agentik/state.db
# Относительный путь — резолвится от CWD
cd /opt/agentik && AGENTIK_DB_PATH=./data/state.db
# Временная база (на RAM, теряется при рестарте) — не поддерживается напрямую,
# но можно подменить в коде через SqliteStores.inMemory().
```
Файл базы — обычный SQLite, можно инспектировать `sqlite3` CLI или Adminer.
## Контекст инициации сообщения (MessageContext)
Каждое user-сообщение может нести **контекст инициации хода** —
кто/что его вызвало. Для обычного user-сообщения поле `context` опускается
(обратная совместимость: старые клиенты, шлющие голый массив Content,
работают как раньше). Для cron/webhook/system событий `context` обязательно.
```json
// POST /agentik/conversations/{id}/messages — новый формат
{
"content": [{"type": "text", "body": "wake up"}],
"context": {
"origin": "event",
"description": "scheduled cron morning-briefing",
"sourceId": "cron-42",
"metadata": { "scheduledAt": "2026-09-14T08:00:00Z" }
}
}
// Старый формат (всё ещё работает) — голый массив
[{"type": "text", "body": "hello"}]
```
| `origin` | Когда использовать | Префикс в LLM |
|------------|---------------------------------------------------|--------------|
| `user` | Обычное сообщение из чата (дефолт) | нет |
| `system` | Программное сообщение (старт агента, режим обслуживания) | `[SYSTEM] description (sourceId=…)` |
| `event` | Cron, webhook, file-changed, внешний триггер | `[EVENT] description (sourceId=…)` |
**Семантика:**
- `origin` — кто/что инициировал ход. `user` = человек в чате (UI/IRC/HTTP).
- `description` — короткая человекочитаемая фраза для модели
(обязательна для `system`/`event`).
- `sourceId` — id cron-job'а / webhook endpoint'а / IRC-канала, помогает
в логах и при ручном разборе.
- `metadata` — произвольный JSON, никогда не попадает в LLM-нагрузку
(только в audit log для пост-аналитики).
**Что происходит при не-USER origin'е:**
В working memory текст user-сообщения предваряется префиксом —
например, `[EVENT] scheduled cron morning-briefing (sourceId=cron-42)\n…`.
Модель видит, что её разбудил не пользователь, и может реагировать
иначе (например, не начинать диалог с приветствия). Префикс добавляется
**только к LLM-нагрузке**, в audit log и при `getMessages` возвращается
оригинальный текст + `context` отдельно.
## Сжатие рабочего контекста (compaction)
Когда диалог становится длинным, working memory диалога может превысить
контекстное окно модели. Чтобы этого не случилось, агент умеет **сжимать**
старые ходы в один синтетический `Summary`-блок.
**Включается только при заданном лимите.** Никакого автодетекта по имени
модели — если лимит не задан, агент не сжимает.
```bash
# OpenAI-совместимый бэкенд
export OPENAI_CONTEXT_WINDOW=128000
# Или Google / LiteRT
export AGENTIK_GOOGLE_CONTEXT_WINDOW=32000
```
`AGENTIK_COMPRESSION_THRESHOLD` — доля лимита, при которой запускается
compaction (дефолт `0.8` = 80%):
```bash
export AGENTIK_COMPRESSION_THRESHOLD=0.7 # сжимаем раньше
```
**Что происходит при compaction:**
1. Перед `send()` оценивается количество токенов в системном промпте + history
+ tools (грубая оценка `chars / 4`).
2. Если `estimated / contextWindow ≥ threshold` — асинхронный шаг:
- Старые ходы (User/Assistant, кроме последних 4) скармливаются в
`LiteLlmContextCompactor` — отдельный one-shot LLM-вызов с промптом
«Goal / Active / Resolved / Blocked / Remaining».
- Параллельно `MemoryReviewer.reviewPreCompaction` извлекает из старых
ходов факты и кладёт их в долговременную память (триггер `MemoryStore`).
- Атомарный `working_memory.compact(fromIdx, summary)` — старые строки
удаляются, на их место вставляется одна `Summary` запись.
- `LiteConversation` пересоздаётся с обновлённым контекстом.
Если после compaction оценка всё ещё выше порога — выводится warning, но
нового compaction не запускается (защита от зацикливания). Решение —
поднять `OPENAI_CONTEXT_WINDOW` или понизить threshold.
## Self-reflection (Hermes-style «слабые места»)
Каждые `AGENTIK_REFLECTION_INTERVAL` пользовательских ходов (default 10)
запускается фоновая one-shot LLM-размышление: «оцени последние ходы,
поставь score 1..5, выдели слабые места». Результат сохраняется в таблицу
`reflection` SQLite и подмешивается в system prompt следующего хода как
«Твои слабые места за последнее время».
Включено когда `AGENTIK_REFLECTION_INTERVAL > 0`. Требует LiteLlm
(on-device или OpenAI — что указан в `AGENTIK_LLM_BACKEND`). На каждый
reflection — один LiteLlm вызов (~1-3 сек для on-device, ~200-500мс для
OpenAI). Это происходит в фоне (`Dispatchers.IO`), основной диалог не
блокируется.
Топ-K последних рефлексий загружается в `buildSystemPrompt` и выводится
как `## Self-reflection: твои слабые места за последнее время`. Агент
видит их в каждом следующем ходе и (теоретически) должен избегать
повторения. Используется как cheap "auto-improving prompt feedback"
без ручного переписывания system prompt.
## Учёт токенов (token accounting)
Каждый assistant-ход после LiteLlm.send помечает assistant-запись `TurnTokens(input, output)`:
- **`input`** — снимок `LiteConversation.tokenCount()` **до** первого send в turn'е
(system + вся история + tools + только что добавленное user-сообщение).
- **`output`** — дельта после завершения turn'а (assistant text + tool calls +
tool results, всё что LiteConversation добавила за весь tool loop).
- Хранится в `payload_json` assistant-сообщения (без schema-миграций). Бэкенды
без `tokenCount()` (off-line модели LiteRT-LM счётчик не отдают) дают `tokens=null`.
На старте агент печатает сводку по всем существующим диалогам:
```
tokens: 17 convs, 134 turns, in=523844, out=58290, total=582134
```
`MessageStore.tokenStats(conversationId)` отдаёт `TokenStats(turns, inputTokens, outputTokens)`
для одного диалога — можно использовать из HTTP фасада или клиентских дашбордов
для оценки cost.
## Куратор памяти (Curator)
Фоновая корутина (запускается автоматически, если `AGENTIK_MEMORY_DIR != off`):
раз в сутки архивирует заметки, которые **не выдавались в prefetch дольше 90
дней** и **имеют `useCount == 0`**. Семантика архивации зависит от бэкенда —
`:memory-md` переименовывает §-файл в `.archived.{ts}`, `:memory-vector`
удаляет из SQLite и JVector.
Параметры пока захардкожены в `Curator.DEFAULT_INTERVAL` и
`Curator.DEFAULT_MAX_AGE` (1 день и 90 дней); для override нужен новый
config-флаг. На каждом проходе выводится `[Curator] archived N stale notes`,
если N > 0.
## Память
`AGENTIK_MEMORY_DIR` указывает на каталог, в котором лежат три §-файла:
`user.md`, `world.md`, `preference.md` (по одному на категорию из `MemoryCategory`).
Формат файла — Hermes-style: заголовок с метаданными (` id=… created=… uses=…`),
пустая строка, markdown-тело заметки.
```bash
# Дефолт (если env не задан)
~/.agentik/memory/{user,world,preference}.md
# Явный путь
AGENTIK_MEMORY_DIR=/data/agentik/memory
# Полностью выключить память (тулы memory_* не регистрируются, prefetch off)
AGENTIK_MEMORY_DIR=off
```
При включённой памяти агенту доступны четыре тула: `memory_save`,
`memory_read`, `memory_list`, `memory_delete`. Перед каждым ходом агент
прогоняет текст пользователя через `MemoryPrefetcher` и клеит `[Memory
context…]` блок в начало user-сообщения; после записи assistant-сообщения в
фоне запускается `MemoryReviewer.review(turn)` — извлечённые факты
записываются в store с `source = AUTO_REVIEW`.
### Бэкенд: md vs vector
`AGENTIK_MEMORY_BACKEND` выбирает хранилище. Дефолт — `md` (Hermes-style
§-файлы, keyword overlap, без внешних вызовов).
`vector` — SQLite (`AGENTIK_DB_PATH`) + JVector ANN + эмбеддинги. Два
бэкенда эмбеддингов через `AGENTIK_EMBEDDING_BACKEND`:
- **`HTTP` (default)** — POST на `${OPENAI_BASE_URL}/v1/embeddings`. Семантический
поиск: cosine similarity + recency-re-rank.
- **`SIGLIP`** — on-device SigLIP2 через ONNX Runtime (text-embedding-kmp,
768-мерный вектор). Никаких внешних вызовов: модель и токенизатор должны
лежать на диске. Размерность определяется автоматически (768).
```bash
# Vector-бэкенд + HTTP-эмбеддинги (default)
AGENTIK_MEMORY_BACKEND=vector \
AGENTIK_EMBEDDING_BACKEND=http \
AGENTIK_EMBEDDING_MODEL=text-embedding-3-small \
AGENTIK_EMBEDDING_DIMENSION=1536 \
java -jar standalone-all.jar
# Vector-бэкенд + on-device SigLIP2 (без сети)
AGENTIK_MEMORY_BACKEND=vector \
AGENTIK_EMBEDDING_BACKEND=siglip \
AGENTIK_EMBEDDING_MODEL_PATH=/path/to/text_model_int8.onnx \
AGENTIK_EMBEDDING_TOKENIZER_PATH=/path/to/tokenizer.model \
java -jar standalone-all.jar
```
Для HTTP-эмбеддингов требуется `AGENTIK_LLM_BACKEND=openai` (т.к. нужен
OpenAI-совместимый `/v1/embeddings` endpoint — LiteLLM proxy тоже подходит).
HTTP-вызовы кэшируются LRU на 256 текстов — дедупликация при повторных
запросах одинаковых промптов.
Для SIGLIP нужно сначала скачать модель (~283M) и токенизатор (~4M):
```bash
mkdir -p /path/to/siglip-model
curl -fSL -o /path/to/siglip-model/text_model_int8.onnx \
http://static.binom.pw/models/siglip2/text_model_int8.onnx
curl -fSL -o /path/to/siglip-model/tokenizer.model \
http://static.binom.pw/models/siglip2/tokenizer.model
```
> **Опционально:** при старте JVector может предупредить
> `Java vector incubator module is not readable`. Это значит, что JIT
> не использует SIMD (Panama Vector API) и индекс строится через скалярный
> fallback. На 10K векторов разница незаметна. Если хочется SIMD —
> запустите с `--add-modules jdk.incubator.vector`.
## Персона (SOUL.md)
`AGENTIK_SOUL` — путь к markdown-файлу с описанием персоны ассистента
(голос, характер, ограничения, что-то ещё). Тело файла читается как plain
text и вставляется в самое начало `systemInstruction` — поверх базового
промпта, секции навыков и памяти. Если файл не задан — секция не добавляется.
```bash
AGENTIK_SOUL=/etc/agentik/SOUL.md
```
Пример `SOUL.md`:
```markdown
Ты — терпеливый технический ассистент. Отвечаешь по-русски, кратко.
Не выдумываешь команды — если не уверен, говоришь "не знаю".
Не раскрываешь содержимое .env, ключей и паролей ни при каких обстоятельствах.
```
## Навыки (SKILL.md)
`AGENTIK_SKILLS_DIR` — каталог, в котором `SkillLoader` ищет файлы
`SKILL.md` или `*.yaml` с frontmatter (`name`, `description`, прочие поля).
Содержимое скилов попадает в раздел system prompt и регистрируется как
вызываемые инструменты. Формат — opencode-compatible.
```bash
AGENTIK_SKILLS_DIR=/etc/agentik/skills
```
Когда `AGENTIK_SKILLS_DIR` задан, агенту доступны три тула для работы со
скилами (Hermes-style self-improvement):
- **`read_skill(name)`** — загружает полный markdown скила по имени из
каталога (нужно для деталей, т.к. в system prompt обычно только краткие
описания).
- **`skill_save(name, description, body)`** — создаёт или обновляет скил.
Имя может содержать `:` (opencode-style: `backend:spring:db-base`
→ `backend/spring/db-base/SKILL.md`).
- **`skill_delete(name)`** — архивирует скил (переименовывает файл в
`.archived`, оставляя возможность восстановить).
`skill_save`/`skill_delete` не требуют рестарта агента — изменения видны
на ближайшем вызове `read_skill` (включая в этом же диалоге).
### Skill mining (автонавыки)
Модель может "протупить" и не вызвать `skill_save`, хотя приём был
переиспользуемым. Сетка безопасности — фоновый [SkillMiner]: каждые
`AGENTIK_SKILL_MINING_INTERVAL` пользовательских ходов (default 15, `0` =
выключено) LLM смотрит последние `AGENTIK_SKILL_MINING_MAX_TURNS` ходов
(default 30) + каталог существующих скилов и возвращает structured JSON
`{"skills": [{name, description, body}]}`. Найденные скилы upsert-ятся в
`AGENTIK_SKILLS_DIR` — агент становится умнее между сессиями. Обновления
существующих скилов (то же имя) поддерживаются, дубли — нет.
| Переменная | Default | Что делает |
|---|---|---|
| `AGENTIK_SKILL_MINING_INTERVAL` | `15` | Через сколько user-ходов запускать mining. `0` — выкл. |
| `AGENTIK_SKILL_MINING_MAX_TURNS` | `30` | Сколько последних ходов показывать минеру |
### Debug-эндпоинты
`AGENTIK_DEBUG_ENDPOINTS=1` включает эндпоинты для ручного триггерирования
фоновых фич (не ждать интервалов). Только локальная отладка: без
авторизации, в проде не включать.
| Эндпоинт | Действие |
|---|---|
| `POST /debug/reflect?conversationId=...` | прогон LlmReflector прямо сейчас, результат в БД |
| `POST /debug/skill-mine?conversationId=...` | прогон SkillMiner прямо сейчас, найденное в `AGENTIK_SKILLS_DIR` |
| `POST /debug/curate` | прогон Curator.runPass (архивация stale-заметок памяти) |
| `POST /debug/compact?conversationId=...` | принудительный compaction working memory диалога |
| `GET /debug/tokens?conversationId=...` | token-статистика диалога из БД (turns/in/out/total) |
Каждый возвращает JSON с результатом (что сохранил/нашёл/сжал), чтобы было
видно не только "триггер сработал", а что именно LLM намайнила.
```bash
AGENTIK_SKILLS_DIR=/etc/agentik/skills
AGENTIK_DEBUG_ENDPOINTS=1
```
## MCP-инструменты
`AGENTIK_MCP_CONFIG` — путь к JSON-файлу со списком MCP-серверов
(формат `mcpServers: { name: { command, args | url, headers } }`). При
запуске `McpRegistry.fromConfig` стартует stdio-серверы и подключается к
HTTP-серверам, инструменты автоматически становятся доступны агенту.
```bash
AGENTIK_MCP_CONFIG=/etc/agentik/mcp.json
```
Пример `mcp.json`:
```json
{
"mcpServers": {
"fetch": { "command": "uvx", "args": ["mcp-server-fetch"] },
"playwright": { "url": "https://mcp.example.com", "headers": {"Authorization":"Bearer …"} }
}
}
```
## Полный пример запуска
```bash
export AGENTIK_PORT=8080
export AGENTIK_DB_PATH=/var/lib/agentik/state.db
export AGENTIK_MEMORY_DIR=/var/lib/agentik/memory
export AGENTIK_SOUL=/etc/agentik/SOUL.md
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).
| `AGENTIK_DB_PATH` | `./agentik.db` | Путь к SQLite |
| `AGENTIK_AGENT_ID` | `agentik` | ID агента (для multi-instance) |
| `AGENTIK_LLM_BACKEND` | `openai` | `openai` или `google` |
| `AGENTIK_LLM_MODEL` | (выбирается по backend) | Имя модели |
| `AGENTIK_LLM_API_URL` | `http://localhost:8001/v1` | Endpoint для OpenAI-compatible |
| `AGENTIK_LLM_API_KEY` | `no-key-needed` | Auth header |
| `AGENTIK_LLM_CONTEXT_TOKENS` | `115000` | Сколько токенов остаётся модели |
| `AGENTIK_GOOGLE_MODEL_PATH` | `/root/gemma-4-E2B-it.litertlm` | Путь к `.litertlm` файлу |
| `AGENTIK_GOOGLE_MODEL_URL` | `https://static.binom.pw/models/gemma-4-E2B-it.litertlm` | Откуда скачивать |
| `AGENTIK_GOOGLE_MODEL_SHA256_URL` | — | Если задан — verify по SHA-256 |
| `AGENTIK_AUTO_DOWNLOAD_MODEL` | `0` | `1` = скачать модель если её нет |
| `AGENTIK_MEMORY_BACKEND` | `vector` | `md`, `vector` или `off` |
| `AGENTIK_EMBEDDING_BACKEND` | `http` | `http` или `siglip` (только для `vector`) |
| `AGENTIK_EMBEDDING_API_URL` | `http://localhost:8001/v1` | Endpoint для эмбеддингов |
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Имя embedding-модели |
| `AGENTIK_SOUL_PATH` | `~/.agentik/SOUL.md` | Путь к SOUL.md |
| `AGENTIK_SKILLS_DIR` | `~/.agentik/skills/` | Каталог SKILL.md |
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory/` | Каталог для md-памяти |
| `AGENTIK_TOOLSETS_DEFAULT` | `memory,skills,files,web` | Включённые тулы |
| `AGENTIK_DEBUG` | `0` | `1` = verbose logging |
Значения читаются через `AgentikConfig.fromEnv()` в `:standalone/.../Main.kt`.
## Эндпоинты
| Метод | Путь | Transport | Описание |
|---|---|---|---|
| `GET` | `/health` | любой | health-check (`{"ok":true}`) |
| `POST` | `/agui` | AG-UI | Стриминг run (SSE) |
| `POST` | `/` | A2A | JSON-RPC `message/send`, `tasks/get`, `tasks/cancel` |
| `GET` | `/.well-known/agent-card.json` | A2A | Discovery |
| `POST` | `/agentik/conversations` | :proto | Создать диалог |
| `GET` | `/agentik/conversations` | :proto | Список диалогов |
| `GET` | `/agentik/conversations/:id` | :proto | Snapshot |
| `GET` | `/agentik/conversations/:id/messages` | :proto | История |
| `POST` | `/agentik/conversations/:id/send` | :proto | Send (SSE) |
| `GET` | `/agentik/conversations/:id/events` | :proto | Live-events (SSE) |
| `POST` | `/agentik/conversations/:id/interrupt` | :proto | Прервать |
| `POST` | `/agentik/conversations/:id/rename` | :proto | Переименовать |
| `DELETE` | `/agentik/conversations/:id` | :proto | Удалить |
## Тесты
```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-таргеты
публикует артефакты.
+10 -1
View File
@@ -12,6 +12,13 @@ plugins {
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 {
jvmToolchain(21)
@@ -46,7 +53,9 @@ kotlin {
// Долговременная память (Hermes-style MD-бэкенд) + хранилище истории.
implementation(project(":memory-api"))
implementation(project(":memory-md"))
implementation(project(":memory-vector"))
if (!skipVectorMemory) {
implementation(project(":memory-vector"))
}
implementation(project(":storage-core"))
implementation(project(":storage-sqlite"))
implementation(project(":agent-toolsets"))
@@ -1,5 +1,6 @@
package pw.binom.agentik.standalone
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
@@ -26,6 +27,7 @@ import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer
import pw.binom.agentik.standalone.config.AgentikConfig
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.ModelDownloader
import pw.binom.agentik.standalone.mcp.McpRegistry
import pw.binom.agentik.storage.sqlite.SqliteStores
import java.io.File
@@ -54,8 +56,143 @@ import java.io.File
* - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`)
*/
private val log = KotlinLogging.logger {}
fun main() {
fun main(args: Array<String>) {
if (args.isNotEmpty()) {
when (args[0]) {
"pull-model" -> {
runPullModel(args.drop(1))
return
}
"--help", "-h", "help" -> {
printHelp()
return
}
else -> {
System.err.println("Unknown subcommand: ${args[0]}")
printHelp()
kotlin.system.exitProcess(2)
}
}
}
runServer()
}
private fun printHelp() {
println("""
agentik standalone — usage:
java -jar agentik.jar Start HTTP server (AGUI + A2A + :proto)
java -jar agentik.jar pull-model Download the LiteRT-LM model from static.binom.pw
""".trimIndent())
}
/**
* Subcommand `pull-model`: скачивает LiteRT-LM-модель по конфигу.
*
* Конфиг читается из тех же env, что и server: `AGENTIK_LLM_BACKEND=google`
* (если не google — exit 2), `AGENTIK_GOOGLE_MODEL_PATH` (куда), и опциональный
* `AGENTIK_GOOGLE_MODEL_URL` (откуда; дефолт — Gemma-4-E2B-it.litertlm с
* static.binom.pw).
*
* SHA-256 проверяется если задан `AGENTIK_GOOGLE_MODEL_SHA256_URL`.
*
* Если файл по PATH уже есть и совпадает по размеру с HEAD — no-op (exit 0).
*/
private fun runPullModel(args: List<String>) {
val config = AgentikConfig.fromEnv()
val google = config.llm.google
?: error("pull-model: требуется AGENTIK_LLM_BACKEND=google (сейчас ${config.llm.backend})")
val url = System.getenv("AGENTIK_GOOGLE_MODEL_URL")
?.takeIf { it.isNotBlank() }
?: ModelDownloader.DEFAULT_GEMMA_URL
val sha256Url = System.getenv("AGENTIK_GOOGLE_MODEL_SHA256_URL")
?.takeIf { it.isNotBlank() }
println("pull-model: downloading from $url")
println("pull-model: saving to ${google.modelPath}")
if (sha256Url != null) println("pull-model: SHA-256 verification enabled ($sha256Url)")
val downloader = ModelDownloader()
val result = runBlocking {
downloader.download(
url = url,
destPath = google.modelPath,
sha256Url = sha256Url,
progress = { downloaded, total ->
val pct = if (total > 0) (downloaded * 100.0 / total).toInt() else -1
val human = if (total > 0) {
"${formatBytes(downloaded)} / ${formatBytes(total)} ($pct%)"
} else {
formatBytes(downloaded)
}
print("\r $human ")
},
)
}
println()
if (result.bytes == 0L) {
println("pull-model: already present (${formatBytes(result.total)}), nothing to do")
} else if (result.resumedFrom > 0) {
println("pull-model: resumed from ${formatBytes(result.resumedFrom)}, added ${formatBytes(result.bytes - result.resumedFrom)} in ${result.duration}")
} else {
println("pull-model: downloaded ${formatBytes(result.bytes)} in ${result.duration}")
}
}
private fun formatBytes(b: Long): String = when {
b < 1024 -> "$b B"
b < 1024L * 1024 -> "%.1f KB".format(b / 1024.0)
b < 1024L * 1024 * 1024 -> "%.1f MB".format(b / 1024.0 / 1024.0)
else -> "%.2f GB".format(b / 1024.0 / 1024.0 / 1024.0)
}
private fun runServer() {
val config = AgentikConfig.fromEnv()
// Перед созданием LLM: если backend=google и файл по AGENTIK_GOOGLE_MODEL_PATH
// отсутствует — качаем автоматически (только при AGENTIK_AUTO_DOWNLOAD_MODEL=1),
// иначе exit с понятной ошибкой.
if (config.llm.backend == LlmBackend.GOOGLE) {
val google = config.llm.google!!
val modelFile = File(google.modelPath)
if (!modelFile.exists()) {
val url = System.getenv("AGENTIK_GOOGLE_MODEL_URL")
?.takeIf { it.isNotBlank() }
?: ModelDownloader.DEFAULT_GEMMA_URL
val sha256Url = System.getenv("AGENTIK_GOOGLE_MODEL_SHA256_URL")
?.takeIf { it.isNotBlank() }
val autoDownload = System.getenv("AGENTIK_AUTO_DOWNLOAD_MODEL") == "1"
if (autoDownload) {
log.warn { "auto-download: $url -> ${google.modelPath}" }
val dl = ModelDownloader()
val result = runBlocking {
dl.download(
url = url,
destPath = google.modelPath,
sha256Url = sha256Url,
progress = { d, t ->
val pct = if (t > 0) (d * 100.0 / t).toInt() else -1
if (t > 0) log.info { "auto-download: $pct% (${formatBytes(d)}/${formatBytes(t)})" }
},
)
}
log.info { "auto-download: done in ${result.duration}" }
} else {
System.err.println(
"""
|LiteRT-LM model file not found at: ${google.modelPath}
|
|Чтобы скачать автоматически, установите AGENTIK_AUTO_DOWNLOAD_MODEL=1
|Чтобы скачать руками:
| java -jar agentik.jar pull-model
|(URL по умолчанию: $url)
""".trimMargin(),
)
kotlin.system.exitProcess(2)
}
}
}
val llm = config.llm.createLlm()
val storage = SqliteStores.open(dbPath = config.dbPath).asBundle()
@@ -116,6 +116,16 @@ class ChatAgent(
private val toolsets: List<ToolsetContribution> = emptyList(),
) : ProtoAgent, AutoCloseable {
/**
* Test-only: регистрирует дополнительный tool в общий [toolsByName] ПОСЛЕ
* создания ChatAgent. Используется в тестах `interrupt mid-tool` для
* симуляции долгого tool-вызова, который можно прервать через interrupt().
* В production этот API НЕ используется — тулы статичны через конструктор.
*/
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) {
toolsByName[name] = NamedTool(name = name, tool = tool)
}
/**
* Реестр активных тулсетов — один на агента (per-agent state).
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations.
@@ -164,7 +174,7 @@ class ChatAgent(
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
}
private val toolsByName: Map<String, NamedTool> = allTools.associateBy { it.name }
private val toolsByName: MutableMap<String, NamedTool> = allTools.associateBy { it.name }.toMutableMap()
/**
* Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда
@@ -212,11 +222,6 @@ class ChatAgent(
if (!temp) {
runBlocking {
storage.conversationStore.upsert(rec)
storage.workingMemoryStore.append(
conversationId = id,
entry = WorkingMemoryEntry.System(text = systemPrompt),
now = now,
)
}
}
val conv = ChatConversation(
@@ -2,11 +2,15 @@ package pw.binom.agentik.standalone.agent
import mu.KotlinLogging
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -15,6 +19,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryPrefetcher
@@ -155,7 +161,16 @@ class ChatConversation(
private val messageStore: MessageStore get() = storage.messageStore
private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore
private val toolsByName: Map<String, NamedTool> = tools.associateBy { it.name }
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
/**
* Test-only: регистрирует дополнительный tool в [toolsByName] ПОСЛЕ создания
* ChatConversation. Используется в тестах `interrupt mid-tool` для симуляции
* долгого tool-вызова, который можно прервать через interrupt().
*/
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) {
toolsByName[name] = NamedTool(name = name, tool = tool)
}
private val events = MutableSharedFlow<ProtoEvent>(
replay = 0,
@@ -164,7 +179,15 @@ class ChatConversation(
)
private val turnLock = Mutex()
// Основной scope для активного turn'а — IO (много потоков, не упираемся).
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Фоновые задачи (review/reflection/skill-mining) — отдельный pool с
// bounded parallelism. Они делают sync LLM-вызовы через runBlocking внутри
// LiteLlm.send() — если запустить 30+ параллельно (по одному на беседу),
// упираемся в IO-thread starvation и все повисают в очереди.
private val backgroundScope = CoroutineScope(
SupervisorJob() + Dispatchers.IO.limitedParallelism(4),
)
@Volatile
private var liteConv: LiteConversation? = null
@@ -173,6 +196,23 @@ class ChatConversation(
@Volatile
private var closed = false
/**
* Флаг прерывания текущего turn'а. Ставится в `true` через [interrupt].
* Проверяется в [runTurn] на каждой итерации tool-loop и в finally-блоке —
* влияет на то, какие финальные события эмитятся и какие записи в working
* memory создаются. Идемпотентен: повторные interrupt() после первого —
* no-op.
*/
private val interrupted = AtomicBoolean(false)
/**
* Текущий выполняющийся tool-call (sub-Job в нашем scope). Ставится в
* [runToolAndPersist] перед `tool.tool.invoke()` и зануляется в finally.
* `interrupt()` делает `currentToolJob?.cancel()` чтобы отменить
* конкретно тулл, не убивая весь activeTurn.
*/
private var currentToolJob: Job? = null
internal val isClosed: Boolean get() = closed
override suspend fun rename(title: String) {
@@ -217,10 +257,29 @@ class ChatConversation(
activeTurn?.join()
}
override suspend fun interrupt() {
override suspend fun interrupt() {
// Сигнал, а не убийство:
// 1. Ставим флаг — runTurn увидит его в finally-блоке и в tool-loop,
// эмитит `Interrupted` event + корректно закроет LiteConv.
// 2. Отменяем in-flight tool-job (если есть) — кооперативная отмена
// через CancellationException внутри `tool.tool.invoke`.
// 3. Отменяем генерацию в LiteRT-LM (`cancelProcess`) — стрим
// `sendStreamContents` бросит CancellationException.
// activeTurn НЕ cancel — даём runTurn'у finally-блоку корректно
// записать state (частичный assistant text + cancelled tool exchanges)
// и эмитить End. close() тоже не вызываем — это сделает finally.
//
// NB: ставим флаг ТОЛЬКО если есть что прерывать. Если вызвали interrupt()
// "в пустоту" (нет активного turn'а), флаг не должен отравлять следующий
// send — иначе runTurn'у следующего хода сразу придётся short-circuit'нуть,
// и пользователь не получит ответа на свой "Ок." после явного cancel.
if (activeTurn?.isActive != true) {
log.info { "interrupt() no-op: no active turn for $id" }
return
}
interrupted.set(true)
runCatching { liteConv?.cancel() }
activeTurn?.cancel()
emitEvent(ProtoEvent.Interrupted(date = now()))
currentToolJob?.cancel()
}
override fun events(after: Instant): Flow<ProtoEvent> =
@@ -249,8 +308,17 @@ class ChatConversation(
* В отличие от старого "void addToolResult + sendStreamContents(" ")" — здесь
* нет фантомного trigger-сообщения: LiteDelta из addToolResult несёт и текст
* и nested tool-calls, и мы их тут же обрабатываем.
*
* **Interrupt-safe.** Весь turn обёрнут в `try/finally` — даже при отмене
* [interrupt] (CancellationException через LiteConv.cancel()) мы записываем
* накопленное состояние (частичный assistant text + все tool-exchanges с
* маркером `[cancelled by user]` для прерванных) и эмитим `Interrupted`
* event перед `End`. LiteConv закрывается в finally — следующий `send()`
* создаст новую LiteConv через `getOrCreateLiteConversation` с честной
* историей из working memory.
*/
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
val wasInterruptedAtEntry = interrupted.get()
if (!record.isTemporal) {
compactPreTurnIfNeeded()
}
@@ -281,7 +349,7 @@ class ChatConversation(
addAll(parts)
}
val liteConv = try {
val conv = try {
getOrCreateLiteConversation(excludeUserSourceId = if (record.isTemporal) null else userRecord.id)
} catch (e: Throwable) {
this.liteConv = null
@@ -290,6 +358,7 @@ class ChatConversation(
}
val reply = StringBuilder()
val toolExchanges = mutableListOf<WorkingMemoryEntry.ToolExchange>()
var currentParts: List<LiteContentPart> = initialParts
var loopGuard = 0
@@ -299,115 +368,251 @@ class ChatConversation(
// даёт нам `output` (то что добавила модель: assistant text + tool
// call args + tool results, естественно накопленные за tool loop).
// Если tokenCount() не поддерживается бэкендом или кидает — tokens останется null.
val tokensAtTurnStart: Int? = readTokenCount(liteConv)
val tokensAtTurnStart: Int? = readTokenCount(conv)
var turnTokens: TurnTokens? = null
var pendingParts: List<LiteContentPart>? = currentParts
while (loopGuard++ < MAX_TOOL_LOOPS) {
// 1) Initial user message: send full text, model may respond with
// text + toolCalls. Subsequent iterations: pendingParts = null →
// skip send, drive via addToolResult loop below.
val collectedCalls = mutableListOf<LiteToolCall>()
if (pendingParts != null) {
try {
liteConv.sendStreamContents(pendingParts!!).collect { delta ->
if (delta.text.isNotEmpty()) {
reply.append(delta.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
}
if (delta.toolCalls.isNotEmpty()) {
collectedCalls.addAll(delta.toolCalls)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Throwable) {
this.liteConv = null
failTurn(e.message ?: e.javaClass.simpleName)
return
}
pendingParts = null
try {
// Если interrupt() пришёл ДО старта turn'а — не дёргаем LLM вообще.
// В finally пишем Interruption/End; assistant skipped потому что ничего
// не было сгенерировано.
if (wasInterruptedAtEntry) {
log.info { "runTurn short-circuit on interrupted-flag-at-entry: $id" }
return
}
// 2) Tool-loop: process collected tool calls. After each tool, feed
// the result back via addToolResult (returns LiteDelta — text +
// possibly nested toolCalls). Cycle exits when model no longer
// requests tools.
var nextCalls = collectedCalls
while (nextCalls.isNotEmpty()) {
val prev = nextCalls
nextCalls = mutableListOf()
for (call in prev) {
val (callId, resultText) = runToolAndPersist(call)
val delta = try {
liteConv.addToolResult(callId = callId, name = call.name, result = resultText)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
// Накапливаем tool_calls из post-tool continuation'ов (вызов
// sendStreamContents после addToolResult нужен для stateless-бэкендов).
// Эти вызовы обрабатываются на СЛЕДУЮЩЕЙ итерации outer-while, чтобы
// избежать бесконечной вложенности в случае моделей/fake'ов, которые
// всегда возвращают tool_calls.
var pendingPostToolCalls: List<LiteToolCall> = emptyList()
while (loopGuard++ < MAX_TOOL_LOOPS) {
// 0) Если interrupt случился до старта sendStreamContents (например во время
// compactPreTurn) — нет ни текста, ни тулов. Просто выходим,
// finally-блок запишет минимальный state и эмит Interrupted.
if (interrupted.get() && pendingParts == null) break
// 1) Initial user message: send full text, model may respond with
// text + toolCalls. Subsequent iterations: pendingParts = null →
// skip send, drive via addToolResult loop below.
val collectedCalls = mutableListOf<LiteToolCall>()
if (pendingParts != null) {
try {
liteConv!!.sendStreamContents(pendingParts!!).collect { delta ->
if (delta.text.isNotEmpty()) {
reply.append(delta.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
}
if (delta.toolCalls.isNotEmpty()) {
collectedCalls.addAll(delta.toolCalls)
}
}
} catch (e: CancellationException) {
// LiteConv был отменён через interrupt() — это нормальный flow.
// Выходим из while, finally-блок запишет state.
log.info { "sendStreamContents cancelled for $id" }
break
} catch (e: Throwable) {
this.liteConv = null
failTurn(e.message ?: e.javaClass.simpleName)
return
}
if (delta.text.isNotEmpty()) {
reply.append(delta.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
pendingParts = null
}
// 2) Tool-loop: process collected tool calls. After each tool, feed
// the result back via addToolResult (returns LiteDelta — text +
// possibly nested toolCalls). Cycle exits when model no longer
// requests tools.
// pendingPostToolCalls (с предыдущей итерации outer-loop'а) обрабатываем
// первыми — если stateless-бэкенд вернул tool_calls в continuation,
// их надо прогнать через tool-loop, прежде чем считать turn завершённым.
var nextCalls = if (pendingPostToolCalls.isNotEmpty()) pendingPostToolCalls else collectedCalls
pendingPostToolCalls = emptyList()
while (nextCalls.isNotEmpty()) {
val prev = nextCalls
nextCalls = mutableListOf()
for (call in prev) {
val exchange = runToolAndPersist(call)
toolExchanges += exchange
// addToolResult — синхронный вызов, тоже может быть отменён
// через LiteConv.cancel() (например при interrupt в середине
// tool-loop'а). В этом случае break из внутреннего while —
// finally сохранит уже накопленные exchanges.
val delta = try {
liteConv!!.addToolResult(callId = exchange.sourceMessageId, name = exchange.toolName, result = exchange.resultText)
} catch (e: CancellationException) {
log.info { "addToolResult cancelled for $id" }
break
} catch (e: Throwable) {
this.liteConv = null
failTurn(e.message ?: e.javaClass.simpleName)
return
}
if (delta.text.isNotEmpty()) {
reply.append(delta.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
}
if (delta.toolCalls.isNotEmpty()) {
nextCalls.addAll(delta.toolCalls)
}
// После addToolResult вызываем sendStreamContents с пустым
// контентом — это триггерит следующий ответ модели после
// tool-result'а. Нужно для stateless-бэкендов (OpenAI):
// addToolResult у них только дописывает в history, реальный
// ответ приходит только при следующем send. Для stateful
// бэкендов (Google LiteRT-LM) addToolResult сам запускает
// генерацию — повторный send будет пустым ответом (isDone),
// collect() просто пропускает.
//
// Если бы мы этого не делали — OpenAI-бэкенд возвращал
// бы только tool_call → tool_result → пустой assistant,
// без финального текста после tool'а.
if (!interrupted.get()) {
try {
// Нельзя передавать emptyList() — LiteMessage требует
// непустой contents. Используем невидимый placeholder
// (пробел) — OpenAI-бэкенд допишет его как user-message
// и триггерит ответ модели. На стороне LiteRT-LM
// (stateful) addToolResult уже выполнил работу, так
// что ответ будет пустой/короткий и мы просто
// проигнорируем его в collect.
//
// NB: собираем ТОЛЬКО text из ответа. tool_calls из
// post-tool continuation добавляются в отдельный буфер
// outer-loop'а — иначе можно попасть в бесконечный
// tool-loop (тестовая fake-LiteLlm, например, всегда
// возвращает tool_call из sendStreamContents).
val collectedPostTool = mutableListOf<LiteToolCall>()
liteConv!!.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
if (followUp.text.isNotEmpty()) {
reply.append(followUp.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = followUp.text))
}
if (followUp.toolCalls.isNotEmpty()) {
collectedPostTool.addAll(followUp.toolCalls)
}
}
// Обрабатываем tool_calls из continuation в outer-loop
// (следующая итерация while), а не в этом же inner-while.
// Устанавливаем флаг, чтобы вернуться к outer.
if (collectedPostTool.isNotEmpty()) {
// Передаём в outer-loop: добавляем в pendingCallsForNextIter
pendingPostToolCalls = collectedPostTool
}
} catch (e: CancellationException) {
log.info { "post-tool sendStreamContents cancelled for $id" }
break
} catch (e: Throwable) {
log.warn(e) { "post-tool sendStreamContents failed for $id" }
break
}
}
}
if (delta.toolCalls.isNotEmpty()) {
nextCalls.addAll(delta.toolCalls)
if (interrupted.get()) break
}
if (nextCalls.isEmpty() && pendingParts == null) break
if (interrupted.get()) break
// (pendingParts != null случай обработан выше; сюда попадём только
// если executeToolCall сам породил вложенный tool-loop и мы хотим
// продолжить — но мы это уже разрулили внутренним while выше.)
if (nextCalls.isEmpty()) break
}
if (loopGuard >= MAX_TOOL_LOOPS) {
log.warn { "tool loop hit MAX_TOOL_LOOPS=$MAX_TOOL_LOOPS for $id — bailing" }
}
// Считаем дельту после цикла (defensive: turnTokens может остаться null).
if (tokensAtTurnStart != null) {
val tokensAtTurnEnd = readTokenCount(conv!!)
if (tokensAtTurnEnd != null) {
val output = (tokensAtTurnEnd - tokensAtTurnStart).coerceAtLeast(0)
turnTokens = TurnTokens(input = tokensAtTurnStart, output = output)
}
}
} finally {
// Закрываем LiteConv в любом случае: при interrupt следующий send
// получит свежую LiteConv с initialMessages из working memory.
runCatching { liteConv?.close() }
this.liteConv = null
// Если была отмена в самом начале turn'а (например до старта sendStreamContents)
// и runTurn вышел через ранний return — wasInterruptedAtEntry = true,
// interrupted.get() = true. Если же мы просто успешно отработали —
// interrupted.get() = false (флаг сбрасывается в конце, после emit).
val wasInterrupted = interrupted.get()
if (!record.isTemporal) {
// В audit log пишем AssistantMessage ТОЛЬКО если turn что-то произвёл
// (текст или tool-exchanges). На чистом прерывании/ошибке ДО первого
// sendStreamContents — пустой Assistant был бы мусором (тест LLM-failure
// ожидает именно [user, error] без пустого assistant).
if (reply.isNotEmpty() || toolExchanges.isNotEmpty()) {
val assistantId = newId("msg")
val assistantAt = now()
val assistantContent = listOf(Content.Text(reply.toString()))
val assistantRecord = MessageRecord.AssistantMessage(
id = assistantId,
conversationId = id,
content = assistantContent,
createdAt = assistantAt,
tokens = turnTokens,
)
messageStore.append(assistantRecord)
// В working_memory пишем Assistant-message — LLM видит его
// как model-role initialMessages при следующем send().
workingMemory.append(
conversationId = id,
entry = WorkingMemoryEntry.Assistant(
sourceMessageId = assistantId,
content = assistantContent,
),
now = assistantAt,
)
// Каждый tool-exchange одной строкой в working memory — для
// replay'а в LiteMessage(TOOL, ToolResult) при пересоздании LiteConv.
for (ex in toolExchanges) {
workingMemory.append(
conversationId = id,
entry = ex,
now = assistantAt,
)
}
record = record.copy(updatedAt = assistantAt)
conversationStore.touch(id, assistantAt)
scheduleReview(userRecord, assistantContent)
scheduleReflection(userRecord, assistantContent)
scheduleSkillMining(userRecord, assistantContent)
}
}
if (nextCalls.isEmpty() && pendingParts == null) break
// (pendingParts != null случай обработан выше; сюда попадём только
// если executeToolCall сам породил вложенный tool-loop и мы хотим
// продолжить — но мы это уже разрулили внутренним while выше.)
if (nextCalls.isEmpty()) break
}
if (loopGuard >= MAX_TOOL_LOOPS) {
log.warn { "tool loop hit MAX_TOOL_LOOPS=$MAX_TOOL_LOOPS for $id — bailing" }
}
// Считаем дельту после цикла (defensive: turnTokens может остаться null).
if (tokensAtTurnStart != null) {
val tokensAtTurnEnd = readTokenCount(liteConv)
if (tokensAtTurnEnd != null) {
val output = (tokensAtTurnEnd - tokensAtTurnStart).coerceAtLeast(0)
turnTokens = TurnTokens(input = tokensAtTurnStart, output = output)
// Interrupted event — клиент видит его в SSE сразу как прерывание
// произошло (на самом деле он эмитится в finally, после возможного
// финального ответа модели — это нормально, клиент рендерит оба).
//
// Если interrupt() пришёл ПОСЛЕ того, как мы прочитали wasInterrupted
// в начале finally — перечитываем флаг здесь, чтобы корректно эмитить
// Interrupted event и сбрасывать флаг после.
if (wasInterrupted || interrupted.get()) {
emitEvent(ProtoEvent.Interrupted(date = now()))
}
emitEvent(ProtoEvent.End(date = now()))
// Сбрасываем флаг — следующий turn стартует чистым. Всегда
// (compareAndSet атомарен, защищаем от race-condition: interrupt()
// мог быть вызван между чтением wasInterrupted и этой строкой).
interrupted.set(false)
}
val assistantId = newId("msg")
val assistantAt = now()
val assistantContent = listOf(Content.Text(reply.toString()))
val assistantRecord = MessageRecord.AssistantMessage(
id = assistantId,
conversationId = id,
content = assistantContent,
createdAt = assistantAt,
tokens = turnTokens,
)
if (!record.isTemporal) {
messageStore.append(assistantRecord)
workingMemory.append(
conversationId = id,
entry = WorkingMemoryEntry.Assistant(
sourceMessageId = assistantId,
content = assistantContent,
),
now = assistantAt,
)
record = record.copy(updatedAt = assistantAt)
conversationStore.touch(id, assistantAt)
}
scheduleReview(userRecord, assistantContent)
scheduleReflection(userRecord, assistantContent)
scheduleSkillMining(userRecord, assistantContent)
emitEvent(ProtoEvent.End(date = assistantAt))
}
/**
@@ -473,12 +678,12 @@ class ChatConversation(
private suspend fun compactPreTurn(force: Boolean): Boolean {
val window = contextWindow ?: return false
val compactor = contextCompactor ?: return false
// System prompt живёт только in-memory в ChatConversation.systemPrompt —
// он не участвует ни в working_memory, ни в compaction.
val wm = workingMemory.list(id)
if (wm.isEmpty()) return false
val systemText = wm.firstOrNull { it.entry is WorkingMemoryEntry.System }
?.let { (it.entry as WorkingMemoryEntry.System).text }
?: systemPrompt
val systemText = systemPrompt
val history = wm.filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant }
val toolsChars = tools.sumOf { it.tool.describe().length }
@@ -637,7 +842,7 @@ class ChatConversation(
.joinToString("\n") { it.body }
if (userText.isBlank() || assistantText.isBlank()) return
val convId = id
scope.launch {
backgroundScope.launch {
try {
val decision: MemoryReviewDecision = reviewer.review(
ReviewedTurn(
@@ -685,7 +890,7 @@ class ChatConversation(
val userTurnCount = countUserTurnsBlocking()
if (userTurnCount % reflectionInterval != 0) return
val convId = id
scope.launch {
backgroundScope.launch {
try {
val turns = listOf(
pw.binom.agentik.memory.ConversationTurn(
@@ -734,7 +939,7 @@ class ChatConversation(
val userTurnCount = countUserTurnsBlocking()
if (userTurnCount % skillMiningInterval != 0) return
val convId = id
scope.launch {
backgroundScope.launch {
try {
val turns = recentTurnsFromWorkingMemory(miner.maxTurns)
if (turns.isEmpty()) return@launch
@@ -779,8 +984,14 @@ class ChatConversation(
* пишет в audit + working memory, возвращает пару (callId, текст результата).
* Сам `addToolResult` делает вызывающий — нам нужен callId, который иначе
* негде взять (в LiteToolCall id отсутствует).
*
* **Interrupt-safe.** Tool исполняется в отдельном sub-Job ([currentToolJob]),
* чтобы [interrupt] мог отменить его точечно через `Job.cancel()`. При отмене
* возвращается маркер `[cancelled by user]` — runTurn запишет это в working
* memory как `ToolExchange(wasCancelled = true)`, и LiteConv получает
* честный результат через последующий `addToolResult` (модель видит правду).
*/
private suspend fun runToolAndPersist(call: LiteToolCall): Pair<String, String> {
private suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange {
val callId = newId("tc")
val resultId = newId("tr")
val argsJson = encodeArgsJson(call.arguments)
@@ -801,36 +1012,57 @@ class ChatConversation(
)
}
val resultText: String = if (toolsetDispatch != null) {
// Через тулсет-диспетчер: активный тул выполняется напрямую,
// тул из неактивного тулсета — auto-activate + выполнение,
// неизвестный — fallback в base dispatcher (плоские тулы).
try {
val outcome = toolsetDispatch.dispatch(call.name, argsJson)
when (outcome) {
is ToolsetDispatchPolicy.Outcome.Ran -> outcome.result.ifBlank { "<empty result>" }
is ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: ${call.name}]"
// Запускаем tool в отдельном sub-Job внутри нашего scope. Это даёт
// interrupt() возможность отменить конкретно tool (а не весь activeTurn).
// scope — наш собственный (см. поле `scope` в ChatConversation), живёт
// до close() — независимо от activeTurn.
//
// Сам tool исполняется ВНУТРИ toolsetDispatch.dispatch() (suspend),
// которая оборачивает invoke в runInterruptible(coroutineContext).
// Поэтому при Job.cancel() через currentToolJob — реальный блокирующий
// тред получит Thread.interrupt() → кооперативные blocking tools
// (Thread.sleep, blocking I/O с timeout) будут прерваны.
val toolDeferred = scope.async {
if (toolsetDispatch == null) {
val t = toolsByName[call.name]
if (t == null) {
log.warn { "tool '${call.name}' requested but not registered" }
"[tool not found: ${call.name}]"
} else {
t.tool.invoke(argsJson)
}
} catch (e: Throwable) {
log.warn(e) { "tool '${call.name}' threw: ${e.message}" }
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
}
} else {
val tool = toolsByName[call.name]
if (tool == null) {
log.warn { "tool '${call.name}' requested but not registered" }
"[tool not found: ${call.name}]"
} else {
try {
tool.tool.invoke(argsJson).ifBlank { "<empty result>" }
} catch (e: Throwable) {
log.warn(e) { "tool '${call.name}' threw: ${e.message}" }
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
val d = toolsetDispatch
when (val o = d.dispatch(call.name, argsJson)) {
is ToolsetDispatchPolicy.Outcome.Ran -> o.result
is ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: ${call.name}]"
}
}
}
currentToolJob = toolDeferred
emitEvent(ProtoEvent.ToolResult(date = now(), id = resultId, result = resultText))
val resultText: String = try {
toolDeferred.await()
} catch (e: kotlinx.coroutines.CancellationException) {
// Текущий tool был прерван через interrupt(). Это нормальный flow —
// runTurn увидит cancelled tool в working memory и LiteConv получит
// честный результат через addToolResult.
"[cancelled by user]"
} catch (e: java.lang.InterruptedException) {
// Tool выбросил InterruptedException естественно (тест-фикстура
// ставит cancelFlag и заводит Thread.sleep в loop, видит и кидает).
// runInterruptible не конвертировал — parent Job не был cancelled.
// Но для пользователя это то же самое: tool отменён юзером.
"[cancelled by user]"
} catch (e: Throwable) {
log.warn(e) { "tool '${call.name}' threw: ${e.message}" }
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
} finally {
currentToolJob = null
}
val resultAt = now()
emitEvent(ProtoEvent.ToolResult(date = resultAt, id = resultId, result = resultText))
if (!record.isTemporal) {
messageStore.append(
@@ -839,12 +1071,18 @@ class ChatConversation(
conversationId = id,
toolCallId = callId,
result = resultText,
createdAt = now(),
createdAt = resultAt,
),
)
}
return callId to resultText
return WorkingMemoryEntry.ToolExchange(
sourceMessageId = callId,
toolName = call.name,
toolArgsJson = argsJson,
resultText = resultText,
wasCancelled = resultText == "[cancelled by user]",
)
}
/**
@@ -854,17 +1092,25 @@ class ChatConversation(
private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation {
liteConv?.let { return it }
val wm = if (record.isTemporal) emptyList() else workingMemory.list(id)
val resolvedSystemPrompt = if (record.isTemporal) systemPrompt else wm
.firstOrNull { it.entry is WorkingMemoryEntry.System }
?.let { (it.entry as WorkingMemoryEntry.System).text }
?: systemPrompt
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else wm
// System prompt передаётся в LiteConversationConfig.systemInstruction, не в
// initialMessages. Хранится ТОЛЬКО in-memory в ChatConversation.systemPrompt,
// при каждом создании LiteConversation берётся свежий (отражает текущий SOUL,
// активные toolsets, актуальные skills/reflections на момент старта ChatAgent).
// В working_memory System-entries не пишем — иначе старые диалоги видели бы
// замороженный на момент создания промпт, и SOUL/toolsets не обновлялись бы
// без рестарта агента.
//
// User/Assistant — обычные LiteMessage(user/model, text). ToolExchange —
// синтетический блок "один tool-call + результат", в LiteConv превращается в
// LiteMessage(TOOL, ToolResult). LiteRT-LM матчит по `name` — callId из
// sourceMessageId пробрасывается для трассировки.
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else workingMemory.list(id)
.filter { row ->
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
val isRelevant = row.entry is WorkingMemoryEntry.User
|| row.entry is WorkingMemoryEntry.Assistant
|| row.entry is WorkingMemoryEntry.ToolExchange
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
isUserOrAssistant && !isPendingUser
isRelevant && !isPendingUser
}
.mapNotNull { row ->
val e: WorkingMemoryEntry = row.entry
@@ -874,13 +1120,23 @@ class ChatConversation(
applyContextPrefix(e.content.toLiteContents(), e.context),
)
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
is WorkingMemoryEntry.ToolExchange -> LiteMessage(
LiteRole.TOOL,
listOf(
LiteContentPart.ToolResult(
callId = e.sourceMessageId,
name = e.toolName,
response = e.resultText,
),
),
)
else -> null
}
msg
}
val config = LiteConversationConfig(
systemInstruction = resolvedSystemPrompt.takeIf { it.isNotBlank() },
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
initialMessages = pastTurns,
tools = tools.map { it.tool },
)
@@ -898,6 +1154,9 @@ class ChatConversation(
*
* Благодаря audit-записи ошибка видна не только в live-стриме, но и при
* backfill через `getMessages` (переподключение / polling).
*
* `End` event здесь НЕ эмитим — его эмитит finally-блок в `runTurn`,
* чтобы не было дублей при interrupt/error/success.
*/
private suspend fun failTurn(message: String, code: String? = null) {
val ts = now()
@@ -913,7 +1172,6 @@ class ChatConversation(
)
}
emitEvent(ProtoEvent.Error(date = ts, message = message, code = code))
emitEvent(ProtoEvent.End(date = ts))
}
private fun now(): Instant =
@@ -0,0 +1,274 @@
package pw.binom.agentik.standalone.llm
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.head
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import java.io.File
import java.io.InputStream
import java.io.RandomAccessFile
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import kotlin.time.Duration
import kotlin.time.measureTime
private val log = KotlinLogging.logger {}
/**
* HTTP-скачиватель больших моделей с поддержкой докачки через `Range: bytes=N-`.
*
* Используется двумя сценариями:
* 1. `agentik pull-model` (subcommand в Main.kt) — явный прогон с прогрессом в stdout.
* 2. На старте server'а, если `AGENTIK_AUTO_DOWNLOAD_MODEL=1` — качает только когда
* файла по `AGENTIK_GOOGLE_MODEL_PATH` нет и `AGENTIK_LLM_BACKEND=google`.
*
* Алгоритм:
* 1. HEAD `url` → `Content-Length` (полный размер), `Accept-Ranges: bytes` (опц.).
* 2. Если `<dest>.part` уже есть и его размер < `Content-Length` — открываем
* на append и шлём `Range: bytes=<part.size>-`. Если сервер возвращает
* 200 OK вместо 206 — стартуем с нуля (`.part` удаляется, Range не
* поддерживается).
* 3. Стрим байтов пишем в `.part` через `RandomAccessFile`; каждые ~8 MB
* зовём progress callback.
* 4. На завершении сравниваем `.part.size()` с `Content-Length`; при наличии
* [sha256Url] — проверяем SHA-256.
* 5. Атомарный rename `.part` → финальный путь.
*
* Размер файла на 2026-09 — gemma-4-E2B-it.litertlm ~2.5 GB. Скорость
* ограничена сетью клиента; resume при обрыве обязателен.
*/
class ModelDownloader(
private val httpClient: HttpClient = defaultHttpClient(),
) {
/**
* Скачивает [url] в [destPath]. Если файл уже есть и совпадает с ожидаемым
* размером — возвращает [DownloadResult] с `bytes=0` и не качает заново.
*
* @param destPath абсолютный или относительный путь к финальному файлу.
* @param sha256Url опциональный URL `.sha256` файла для верификации
* (формат `<hex> <basename>` по стандарту `sha256sum` или просто hex).
* Если `null` — верификация пропускается.
* @param progress вызывается периодически с `(downloaded, total)`.
* `total = -1` если неизвестен.
*/
suspend fun download(
url: String,
destPath: String,
sha256Url: String? = null,
progress: (downloaded: Long, total: Long) -> Unit = { _, _ -> },
): DownloadResult {
val destFile = File(destPath).absoluteFile
destFile.parentFile?.mkdirs()
log.info { "model download: HEAD $url" }
val (totalSize, supportsRanges) = probe(url)
// Файл уже на месте и совпадает по размеру — no-op.
if (destFile.exists() && totalSize > 0 && destFile.length() == totalSize) {
log.info { "model download: already present at $destPath (${destFile.length()} bytes), skipping" }
return DownloadResult(bytes = 0, total = totalSize, resumedFrom = 0, duration = Duration.ZERO)
}
val partFile = File("$destPath.part")
val resumedFrom = if (partFile.exists() && partFile.length() > 0 && supportsRanges) {
log.info { "model download: resuming from ${partFile.length()} bytes" }
partFile.length()
} else {
if (partFile.exists() && partFile.length() > 0) {
log.info { "model download: discarding stale .part (server doesn't support Range)" }
partFile.delete()
}
0L
}
val duration = measureTime {
streamToFile(
url = url,
partFile = partFile,
resumedFrom = resumedFrom,
totalSize = totalSize,
progress = progress,
)
}
if (totalSize > 0 && partFile.length() != totalSize) {
error("model download: size mismatch — expected $totalSize bytes, got ${partFile.length()} bytes")
}
if (sha256Url != null) {
verifySha256(partFile, sha256Url)
}
Files.move(
partFile.toPath(),
destFile.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
log.info { "model download: done — $destPath (${destFile.length()} bytes in ${duration})" }
return DownloadResult(
bytes = destFile.length(),
total = totalSize,
resumedFrom = resumedFrom,
duration = duration,
)
}
private data class Probe(val totalSize: Long, val supportsRanges: Boolean)
private suspend fun probe(url: String): Probe {
val resp = httpClient.head(url)
val totalSize = resp.contentLength() ?: -1L
val acceptRanges = resp.headers[HttpHeaders.AcceptRanges]?.equals("bytes", ignoreCase = true) == true
if (totalSize <= 0) {
log.warn { "model download: server did not return Content-Length, progress will be indeterminate" }
}
return Probe(totalSize = totalSize.coerceAtLeast(-1L), supportsRanges = acceptRanges)
}
private suspend fun streamToFile(
url: String,
partFile: File,
resumedFrom: Long,
totalSize: Long,
progress: (Long, Long) -> Unit,
) {
val statement = httpClient.prepareGet(url) {
if (resumedFrom > 0) {
headers.append(HttpHeaders.Range, "bytes=$resumedFrom-")
}
}
// prepareGet().execute { } ловит streaming response — пока лямбда не
// вернулась, bodyAsChannel() читается по сети. Возвращать нужно после
// полной вычитки, иначе Ktor закроет канал и оставшиеся байты пропадут.
statement.execute { response ->
when {
response.status == HttpStatusCode.PartialContent && resumedFrom > 0 -> {
// 206: докачка
}
response.status.isSuccess() -> {
if (resumedFrom > 0) {
log.warn { "model download: server ignored Range, restarting from 0" }
}
}
else -> error("model download: HTTP ${response.status.value} ${response.status.description}")
}
val channel: ByteReadChannel = response.bodyAsChannel()
writeStream(channel, partFile, response.status, resumedFrom, totalSize, progress)
}
}
private suspend fun writeStream(
channel: ByteReadChannel,
partFile: File,
status: HttpStatusCode,
resumedFrom: Long,
totalSize: Long,
progress: (Long, Long) -> Unit,
) {
// Используем InputStream-обёртку поверх ByteReadChannel — она умеет
// блокирующий read(buf) и не требует ручного pump'а через NIO ByteBuffer.
// Преобразование в RandomAccessFile идёт в Dispatchers.IO.
val input: InputStream = channel.toInputStream()
withContext(Dispatchers.IO) {
RandomAccessFile(partFile, "rw").use { raf ->
if (status == HttpStatusCode.PartialContent) {
raf.seek(resumedFrom)
} else {
raf.setLength(0L)
}
val buf = ByteArray(64 * 1024)
var downloaded = if (status == HttpStatusCode.PartialContent) resumedFrom else 0L
var lastReported = downloaded
val reportEvery = 8L * 1024 * 1024 // 8 MB
log.info { "model download: stream begin (resumedFrom=$resumedFrom, totalSize=$totalSize)" }
input.use { stream ->
while (true) {
val read = stream.read(buf)
if (read < 0) break
if (read == 0) {
// Согласно контракту InputStream.read(buf) может вернуть 0
// если buf.length == 0 — не наш случай; но и как защита от
// зацикливания на нулевом чтении даём планировщику тик.
kotlinx.coroutines.yield()
continue
}
raf.write(buf, 0, read)
downloaded += read
if (downloaded - lastReported >= reportEvery || (totalSize in 1..downloaded)) {
progress(downloaded, totalSize)
lastReported = downloaded
}
}
}
progress(downloaded, totalSize)
log.info { "model download: stream end ($downloaded bytes written)" }
}
}
}
private suspend fun verifySha256(file: File, sha256Url: String) {
val expectedHex = httpClient.prepareGet(sha256Url).execute { response ->
response.bodyAsText().trim().substringBefore(' ').lowercase()
}
val actual = withContext(Dispatchers.IO) {
val digest = java.security.MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n <= 0) break
digest.update(buf, 0, n)
}
}
digest.digest().joinToString("") { "%02x".format(it) }
}
if (actual != expectedHex) {
file.delete()
error("model download: SHA-256 mismatch — expected $expectedHex, got $actual")
}
log.info { "model download: SHA-256 verified ($actual)" }
}
data class DownloadResult(
/** Сколько байт записано в текущем прогоне (0 = skip/no-op). */
val bytes: Long,
/** Полный размер файла по Content-Length (`-1` если неизвестен). */
val total: Long,
/** Сколько байт уже было в `.part` до старта текущего прогона (0 = с нуля). */
val resumedFrom: Long,
/** Время, потраченное на саму запись (без HEAD/verify). */
val duration: Duration,
)
companion object {
const val DEFAULT_GEMMA_URL: String =
"https://static.binom.pw/models/gemma-4-E2B-it.litertlm"
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
install(HttpTimeout) {
// connectTimeout — дефолт Ktor (≈ секунды), requestTimeout снимаем:
// скачивание 2.5 GB по медленной сети может занять минуты.
requestTimeoutMillis = Long.MAX_VALUE
}
followRedirects = true
expectSuccess = false
}
}
}
@@ -14,6 +14,8 @@ import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.storage.MessageRecord
import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
@@ -71,32 +73,41 @@ class ChatAgentTest {
)
@Test
fun `createConversation seeds system prompt into working memory`() = runTest {
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
// System prompt живёт ТОЛЬКО in-memory в ChatConversation.systemPrompt
// и едет в LLM через LiteConversationConfig.systemInstruction. В
// working_memory ничего не пишется — старт system prompt чистый,
// 0 entries.
val agent = newAgent()
val conv = agent.createConversation(temp = false) as ChatConversation
val wm = storage.workingMemoryStore.list(conv.id)
assertEquals(1, wm.size)
val first = wm[0]
val sysEntry = first.entry as pw.binom.agentik.storage.WorkingMemoryEntry.System
assertEquals("be brief", sysEntry.text)
assertEquals(0, wm.size)
// System prompt виден через LiteConversationConfig, который LLM получит
// при первом send (см. `send passes system prompt and past history to LLM on first send`).
}
@Test
fun `skills are appended to the system prompt in working memory`() = runTest {
fun `skills are appended to the system prompt passed to LLM`() = runTest {
// Skills добавляются в system prompt на лету при сборке ChatAgent.
// Проверяем это через то, что увидит LLM — systemInstruction в
// LiteConversationConfig (а не через working_memory, куда теперь
// ничего про system prompt не пишется).
val skills = SkillCatalog(
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
)
val agent = newAgent(skills = skills)
val conv = agent.createConversation(temp = false) as ChatConversation
val conv = agent.createConversation(temp = false)
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
val system = storage.workingMemoryStore.list(conv.id).first().entry
as pw.binom.agentik.storage.WorkingMemoryEntry.System
assertTrue("be brief" in system.text)
assertTrue("## Навыки" in system.text)
assertTrue("lint" in system.text)
assertTrue("lint things" in system.text)
assertFalse("SECRET BODY" in system.text, "system prompt must not leak the skill body")
val system = fakeLlm.lastConfig!!.systemInstruction
assertNotNull(system)
assertTrue("be brief" in system!!, "base prompt missing: $system")
assertTrue("## Навыки" in system, "skills section missing: $system")
assertTrue("lint" in system, "skill name missing: $system")
assertTrue("lint things" in system, "skill description missing: $system")
assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body")
}
@Test
@@ -199,12 +210,13 @@ class ChatAgentTest {
fakeLlm.reply = "second reply"
conv2.send(listOf(Content.Text("second user")))
// Первая беседа должна иметь только свою систему + 1 user + 1 assistant
// В working_memory теперь НЕТ System-entries — только user + assistant.
// Системный промт живёт в ChatConversation.systemPrompt и едет в LLM
// через LiteConversationConfig.systemInstruction.
val wm1 = storage.workingMemoryStore.list(conv1.id)
assertEquals(3, wm1.size)
// Вторая беседа — только своё
assertEquals(2, wm1.size)
val wm2 = storage.workingMemoryStore.list(conv2.id)
assertEquals(3, wm2.size)
assertEquals(2, wm2.size)
}
@Test
@@ -229,34 +241,43 @@ class ChatAgentTest {
}
@Test
fun `multi-turn conversation accumulates in single LiteConversation`() = runTest {
fun `multi-turn conversation accumulates history but recreates LiteConv each turn`() = runTest {
// Новая семантика (radical close+recreate после commit 7):
// каждый turn закрывает LiteConv и на следующем send() создаёт новую
// через getOrCreateLiteConversation, которая пересобирает initialMessages
// из working memory. То есть LiteConv — один на turn, не на диалог.
// Преимущество: interrupt можно сделать тривиально (close + cancelProcess),
// KV-cache жертвуем ради предсказуемости (~2s prefill на Gemma-4-E2B).
val agent = newAgent()
fakeLlm.rememberHistory = true
val conv = agent.createConversation(temp = false)
fakeLlm.reply = "first reply"
conv.send(listOf(Content.Text("first user")))
// первый turn: WM = [system, user, assistant]
assertEquals(3, storage.workingMemoryStore.list(conv.id).size)
// первый turn: WM = [user, assistant]
assertEquals(2, storage.workingMemoryStore.list(conv.id).size)
fakeLlm.reply = "second reply"
conv.send(listOf(Content.Text("second user")))
// второй turn: WM должен вырасти до [system, user, assistant, user, assistant]
// второй turn: WM должен вырасти до [user, assistant, user, assistant]
val wm = storage.workingMemoryStore.list(conv.id)
assertEquals(5, wm.size)
// Длинно-живущий LiteConversation: один на ChatConversation, история
// накапливается через sendStreamContents, без пересоздания.
assertEquals(1, fakeLlm.conversations.size)
val history = fakeLlm.conversations[0].history
assertEquals(4, history.size)
assertEquals("first user", history[0].text)
assertEquals(LiteRole.USER, history[0].role)
assertEquals("first reply", history[1].text)
assertEquals(LiteRole.MODEL, history[1].role)
assertEquals("second user", history[2].text)
assertEquals(LiteRole.USER, history[2].role)
assertEquals("second reply", history[3].text)
assertEquals(LiteRole.MODEL, history[3].role)
System.err.println("[TEST] wm.size=${wm.size}")
wm.forEachIndexed { i, row -> System.err.println("[TEST] $i: ${row.entry::class.simpleName} id=${row.id}") }
assertEquals(4, wm.size)
// Новая семантика: один LiteConv на turn → два LiteConv после двух send'ов.
assertEquals(2, fakeLlm.conversations.size)
// Второй LiteConv создан с initialMessages из working memory, ИСКЛЮЧАЯ pending user2
// (он передаётся в sendStreamContents, чтобы не дублироваться).
val reopened = fakeLlm.conversations.last()
assertEquals(2, reopened.initialMessages.size)
assertEquals("first user", reopened.initialMessages[0].text)
assertEquals(LiteRole.USER, reopened.initialMessages[0].role)
assertEquals("first reply", reopened.initialMessages[1].text)
assertEquals(LiteRole.MODEL, reopened.initialMessages[1].role)
// После sendStreamContents (с user2 + сгенерированный asst2) mutableHistory = 4
assertEquals(4, reopened.history.size)
assertEquals("second reply", reopened.history.last().text)
assertEquals(LiteRole.MODEL, reopened.history.last().role)
}
@Test
@@ -318,11 +339,22 @@ class ChatAgentTest {
}
@Test
fun `interrupt cancels active send`() = runTest {
fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest {
// Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel()
// бросает CancellationException в стриме, runTurn выходит через finally.
// Если turn не успел ничего сгенерить (reply.isEmpty() && toolExchanges.isEmpty())
// — AssistantMessage в audit log НЕ пишется. Только user + End/Interrupted.
val agent = newAgent()
fakeLlm.slow = true
val conv = agent.createConversation(temp = false)
// Подписываемся на events ДО send() — SharedFlow без replay, после
// отправки событий подписка ничего не увидит.
val events = mutableListOf<ProtoEvent>()
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
conv.events(Instant.DISTANT_PAST).collect { events.add(it) }
}
val sendJob = launch {
try {
conv.send(listOf(Content.Text("hi")))
@@ -334,11 +366,84 @@ class ChatAgentTest {
delay(200)
conv.interrupt()
sendJob.join()
eventsJob.cancel()
// user сообщение в audit должно быть, assistant — нет (был отменён)
// audit: только user (assistant не успел сгенериться)
val msgs = storage.messageStore.listAll(conv.id)
assertEquals(1, msgs.size)
assertIs<pw.binom.agentik.storage.MessageRecord.UserMessage>(msgs[0])
// working memory: только user (assistant skipped because пустой)
val wm = storage.workingMemoryStore.list(conv.id)
assertEquals(1, wm.size)
assertTrue(wm[0].entry is WorkingMemoryEntry.User)
// events: должны включать Interrupted + End
assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events")
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
}
@Test
fun `interrupt after tool execution preserves tool result in working memory`() = runTest {
// Сценарий "LLM вызвал тул, инструмент выполнился, потом interrupt()":
// 1. LLM скриптован на ToolCalls([echo_tool])
// 2. Tool реально вызывается через toolsetDispatch.dispatch()
// 3. interrupt() приходит в окне между финальным text и завершением turn'а
//
// В audit log: user + ToolCall + ToolResult (инструмент выполнился).
// В working_memory: user + ToolExchange(result=echo output, wasCancelled=false).
// В events: ToolCall + ToolResult + Interrupted + End.
val agent = newAgent()
val conv = agent.createConversation(temp = false)
// LLM скриптован: tool call.
fakeLlm.scriptedReplies = mutableListOf(
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))),
)
val echoTool = object : LiteTool {
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
}
agent.registerToolForTest("echo_tool", echoTool)
// Подписываемся ДО send — SharedFlow без replay
val events = mutableListOf<ProtoEvent>()
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
conv.events(Instant.DISTANT_PAST).collect { events.add(it) }
}
val sendJob = launch {
try {
conv.send(listOf(Content.Text("run echo tool")))
} catch (_: kotlinx.coroutines.CancellationException) {}
}
// Ждём пока инструмент выполнится (turn завершится нормально)
sendJob.join()
// interrupt() ПОСЛЕ завершения turn — не должно ничего менять в БД,
// но проверяем что events включает все ожидаемые типы.
conv.interrupt()
eventsJob.cancel()
// audit: user + toolcall + toolresult (tool выполнился), assistant может быть
val msgs = storage.messageStore.listAll(conv.id)
val toolResult = msgs.filterIsInstance<pw.binom.agentik.storage.MessageRecord.ToolResult>().firstOrNull()
assertNotNull(toolResult, "tool result должен быть в audit — tool выполнился нормально")
val toolResultResult = toolResult!!.result!!
assertTrue(toolResultResult.contains("echo"), "tool result содержит реальный ответ тулы: $toolResultResult")
// working memory: user + tool_exchange
val wm = storage.workingMemoryStore.list(conv.id)
val exchanges = wm.mapNotNull { (it.entry as? WorkingMemoryEntry.ToolExchange) }
assertEquals(1, exchanges.size)
assertEquals("echo_tool", exchanges[0].toolName)
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
assertTrue(exchanges[0].resultText.contains("echo"))
// events должны включать ToolCall + ToolResult. End — обязательно (turn завершился).
assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events")
assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events")
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
}
@Test
@@ -435,7 +540,11 @@ class ChatAgentTest {
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
assertEquals(1, toolLlm.toolCallCount, "expected one round-trip through LiteConversation")
// sendStreamContents вызывается дважды: первый раз с user-сообщением
// (LLM отвечает tool_call), второй раз — после addToolResult — для
// триггера continuation у stateless-бэкендов (OpenAI). На этой fake
// LiteLlm оба попадают в счётчик.
assertEquals(2, toolLlm.toolCallCount, "expected user send + post-tool continuation")
assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult,
"expected echo tool invoked with the LLM's args, result fed back via addToolResult")
assertEquals("final reply", toolLlm.finalReplyEmitted,
@@ -9,18 +9,32 @@ import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, Map<String, Any?>>>) : Reply()
}
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
/**
* Если задан, LLM проходит по этому списку ответов по порядку: первый
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
* кончился — fallback на [reply] (text).
*/
var scriptedReplies: MutableList<Reply> = mutableListOf()
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
@@ -41,6 +55,9 @@ internal class FakeLiteLlm : LiteLlm {
throw UnsupportedOperationException("not used in test")
override fun close() {}
fun nextReply(): Reply =
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
}
internal class FakeLiteConversation(
@@ -60,21 +77,31 @@ internal class FakeLiteConversation(
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
if (parent.slow) {
return kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = parent.reply.substring(0, parent.reply.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = parent.reply.substring(parent.reply.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(parent.reply))
val next = parent.nextReply()
return when (next) {
is FakeLiteLlm.Reply.Text -> {
if (parent.slow) {
kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(next.text))
}
} else {
val first = next.text.substring(0, next.text.length / 2)
val second = next.text.substring(next.text.length / 2)
flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also { mutableHistory.add(LiteMessage.model(next.text)) }
}
}
is FakeLiteLlm.Reply.ToolCalls -> {
val calls = next.calls.map { (name, args) ->
LiteToolCall(name = name, arguments = args)
}
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
}
}
val first = parent.reply.substring(0, parent.reply.length / 2)
val second = parent.reply.substring(parent.reply.length / 2)
return flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also {
mutableHistory.add(LiteMessage.model(parent.reply))
}
}
override fun send(prompt: String): String {
@@ -29,7 +29,6 @@ import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.sqlite.SqliteStores
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -88,11 +87,13 @@ class MemoryWiringTest {
val system = openMdMemorySystem(root)
val agent = newAgent(system.store, system.prefetcher, system.reviewer)
val conv = agent.createConversation(temp = false)
val wm = storage.workingMemoryStore.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
"system prompt should contain MEMORY_GUIDANCE")
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
// System prompt не пишется в working_memory — читаем то, что увидит LLM
val text = fakeLlm.lastConfig?.systemInstruction
assertNotNull(text)
assertTrue(text!!.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
"system prompt should contain MEMORY_GUIDANCE; got first 200 chars: ${text.take(200)}")
agent.close()
system.close()
}
@@ -112,10 +113,11 @@ class MemoryWiringTest {
soulBody = soulBody,
)
val conv = agent.createConversation(temp = false)
val wm = storage.workingMemoryStore.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.startsWith(soulBody),
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
val text = fakeLlm.lastConfig?.systemInstruction
assertNotNull(text)
assertTrue(text!!.startsWith(soulBody),
"soul should be the very first section; got first 60 chars: ${text.take(60)}")
assertTrue(text.contains("be brief"),
"base prompt should still follow the soul; got: $text")
@@ -135,10 +137,11 @@ class MemoryWiringTest {
),
)
val conv = agent.createConversation(temp = false)
val wm = storage.workingMemoryStore.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.startsWith("be brief"),
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
val text = fakeLlm.lastConfig?.systemInstruction
assertNotNull(text)
assertTrue(text!!.startsWith("be brief"),
"without soul, prompt should start with base; got first 60 chars: ${text.take(60)}")
agent.close()
}
@@ -0,0 +1,174 @@
package pw.binom.agentik.standalone.llm
import io.ktor.client.HttpClient
import io.ktor.server.application.call
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.head
import io.ktor.server.routing.routing
import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpHeaders as KH
import io.ktor.utils.io.toByteArray
import java.io.File
import java.net.ServerSocket
import java.nio.file.Path
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlinx.coroutines.runBlocking
class ModelDownloaderTest {
private val payload = ByteArray(8192) { (it and 0xff).toByte() }
/** Поднимает fake-HTTP-server с поддержкой HEAD/GET/Range и возвращает `port`. */
private fun startFakeServer(): FakeServer {
val port = ServerSocket(0).use { it.localPort }
val server = embeddedServer(CIO, port = port) {
routing {
head("/model.litertlm") {
call.response.headers.append(KH.AcceptRanges, "bytes")
call.response.headers.append(KH.ContentLength, payload.size.toString())
call.respondText("")
}
get("/model.litertlm") {
val range = call.request.headers[KH.Range]
if (range == null) {
call.response.headers.append(KH.ContentLength, payload.size.toString())
call.respondBytes(payload)
} else {
// Parse "bytes=N-"
val n = range.substringAfter("bytes=").substringBefore('-').toLong()
val slice = payload.copyOfRange(n.toInt(), payload.size)
call.response.status(HttpStatusCode.PartialContent)
call.response.headers.append(KH.ContentRange, "bytes $n-${payload.size - 1}/${payload.size}")
call.response.headers.append(KH.ContentLength, slice.size.toString())
call.respondBytes(slice)
}
}
}
}
server.start(wait = false)
return FakeServer(server, port)
}
private fun tmpFile(): File {
val dir: Path = createTempDirectory(prefix = "agentik-test-")
return dir.resolve("model.litertlm").toFile()
}
@Test
fun `download writes full file when no part exists`() = runBlocking {
val fake = startFakeServer()
try {
val dest = tmpFile()
val dl = ModelDownloader()
val result = dl.download(
url = "http://127.0.0.1:${fake.port}/model.litertlm",
destPath = dest.absolutePath,
)
assertEquals(payload.size.toLong(), result.bytes)
assertEquals(0L, result.resumedFrom)
assertContentEquals(payload, dest.readBytes())
assertTrue(!File("${dest.absolutePath}.part").exists(), "part file should be cleaned up")
} finally {
fake.server.stop(100, 200)
}
}
@Test
fun `download is no-op when final file already complete`() = runBlocking {
val fake = startFakeServer()
try {
val dest = tmpFile()
dest.writeBytes(payload)
val dl = ModelDownloader()
val result = dl.download(
url = "http://127.0.0.1:${fake.port}/model.litertlm",
destPath = dest.absolutePath,
)
assertEquals(0L, result.bytes)
assertEquals(payload.size.toLong(), result.total)
assertContentEquals(payload, dest.readBytes())
} finally {
fake.server.stop(100, 200)
}
}
@Test
fun `download resumes from existing part file with Range request`() = runBlocking {
val fake = startFakeServer()
try {
val dest = tmpFile()
val partFile = File("${dest.absolutePath}.part")
val prefixSize = 4096
partFile.writeBytes(payload.copyOfRange(0, prefixSize))
val dl = ModelDownloader()
val result = dl.download(
url = "http://127.0.0.1:${fake.port}/model.litertlm",
destPath = dest.absolutePath,
)
assertEquals(prefixSize.toLong(), result.resumedFrom)
assertEquals(payload.size.toLong(), result.bytes)
assertContentEquals(payload, dest.readBytes())
} finally {
fake.server.stop(100, 200)
}
}
@Test
fun `download reports progress via callback`() = runBlocking {
val fake = startFakeServer()
try {
val dest = tmpFile()
val dl = ModelDownloader()
val reports = mutableListOf<Pair<Long, Long>>()
dl.download(
url = "http://127.0.0.1:${fake.port}/model.litertlm",
destPath = dest.absolutePath,
progress = { d, t -> reports += d to t },
)
assertTrue(reports.isNotEmpty(), "progress must be reported at least once")
assertEquals(payload.size.toLong(), reports.last().first)
assertEquals(payload.size.toLong(), reports.last().second)
} finally {
fake.server.stop(100, 200)
}
}
@Test
fun `download fails with clear message on HTTP 404`() = runBlocking {
// Spin up server that always 404s
val port = ServerSocket(0).use { it.localPort }
val server = embeddedServer(CIO, port = port) {
routing {
head("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
get("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
}
}
server.start(wait = false)
try {
val dest = tmpFile()
val dl = ModelDownloader()
try {
dl.download(url = "http://127.0.0.1:$port/missing", destPath = dest.absolutePath)
fail("expected failure on 404")
} catch (e: Exception) {
val msg = e.message ?: ""
assertTrue("404" in msg || "Not Found" in msg,
"error should mention HTTP 404, got: $msg")
}
} finally {
server.stop(100, 200)
}
}
private data class FakeServer(val server: io.ktor.server.engine.EmbeddedServer<*, *>, val port: Int)
}
@@ -150,11 +150,6 @@ class PersistenceTest {
fun `working memory — append + list preserves order`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
stores.workingMemory.append(
conversationId = "c1",
entry = WorkingMemoryEntry.System(text = "you are a bot"),
now = t0,
)
stores.workingMemory.append(
conversationId = "c1",
entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))),
@@ -166,32 +161,29 @@ class PersistenceTest {
now = t1,
)
val list = stores.workingMemory.list("c1")
assertEquals(3, list.size)
assertTrue(list[0].entry is WorkingMemoryEntry.System)
assertTrue(list[1].entry is WorkingMemoryEntry.User)
assertTrue(list[2].entry is WorkingMemoryEntry.Assistant)
assertEquals(2, list.size)
assertTrue(list[0].entry is WorkingMemoryEntry.User)
assertTrue(list[1].entry is WorkingMemoryEntry.Assistant)
}
@Test
fun `working memory — compact without summary just drops tail`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
val rows = stores.workingMemory.list("c1")
// Drop начиная со второго хода (User m1) — должно остаться System.
// Drop начиная со второго хода (User m1) — должно остаться только User m1.
val dropFrom = rows[1].orderIdx
stores.workingMemory.compact(dropFrom, "c1", summaryText = null)
val after = stores.workingMemory.list("c1")
assertEquals(1, after.size)
assertTrue(after[0].entry is WorkingMemoryEntry.System)
assertTrue(after[0].entry is WorkingMemoryEntry.User)
}
@Test
fun `working memory — compact with summary inserts Summary entry`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
@@ -200,7 +192,7 @@ class PersistenceTest {
stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1")
val after = stores.workingMemory.list("c1")
assertEquals(2, after.size)
assertTrue(after[0].entry is WorkingMemoryEntry.System)
assertTrue(after[0].entry is WorkingMemoryEntry.User)
val summary = after[1].entry
assertIs<WorkingMemoryEntry.Summary>(summary)
assertTrue(summary.text.startsWith("**Goal**"))
@@ -213,31 +205,30 @@ class PersistenceTest {
@Test
fun `working memory — compact with blank summaryText behaves as drop`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
val rows = stores.workingMemory.list("c1")
stores.workingMemory.compact(rows[1].orderIdx, "c1", summaryText = "")
stores.workingMemory.compact(rows[0].orderIdx + 1, "c1", summaryText = "")
val after = stores.workingMemory.list("c1")
assertEquals(1, after.size)
assertTrue(after[0].entry is WorkingMemoryEntry.System)
assertTrue(after[0].entry is WorkingMemoryEntry.User)
}
@Test
fun `working memory — compact is atomic on other conversations`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys1"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
stores.workingMemory.append("c2", WorkingMemoryEntry.System("sys2"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0)
stores.workingMemory.append("c2", WorkingMemoryEntry.Assistant("m3", listOf(Content.Text("a2"))), t0)
stores.workingMemory.compact(2, "c1", summaryText = "sum")
val c1 = stores.workingMemory.list("c1")
val c2 = stores.workingMemory.list("c2")
// c1: System + Summary
// c1: User m1 + Summary
assertEquals(2, c1.size)
assertTrue(c1[1].entry is WorkingMemoryEntry.Summary)
// c2 не тронут
assertEquals(2, c2.size)
assertTrue(c2[1].entry is WorkingMemoryEntry.User)
assertTrue(c2[1].entry is WorkingMemoryEntry.Assistant)
}
@Test
+77
View File
@@ -0,0 +1,77 @@
# `:storage-core` — контракт хранилища (KMP, jvm + native)
## Что это
Интерфейсы persistence-уровня для `:standalone`:
- `MessageStore` — append-only история сообщений по диалогу.
- `WorkingMemoryStore` — rolling buffer текущего хода (`AssistantMessage`,
`ToolExchange`, `UserMessage`, system-prompt) для fast-recovery
при reconnect/relance.
- `ConversationStore` — метаданные диалогов (id, title, model,
timestamps).
- `ReflectionStore` — LLM-reflections (свободная форма заметок
хранителя).
Решает: позволяет запустить агента на Android-in-memory, на
desktop-SQLite, или на production-SQLite, не переписывая логику.
Контракт минимален и async-friendly.
## Где используется
- `:storage-inmemory` — для тестов и Android.
- `:storage-sqlite` — прод (Desktop / server / однодесктопный
Android-development).
## Как подключить
```kotlin
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:storage-core:0.1.0")
}
}
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-storage-core`.
## Что в API
```kotlin
interface MessageStore {
suspend fun append(conversationId: String, message: Message): Unit
suspend fun after(conversationId: String, instant: Instant, limit: Int = 100): List<Message>
}
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
}
```
## Тесты
Контрактные тесты (общие для всех имплементаций) — в `:storage-sqlite`
и `:storage-inmemory`.
## Чего здесь НЕТ
- Никакого HTTP / SSE.
- Никакой конкретной БД. Backend'ы в `:storage-*`.
## Текущий статус
Используется продакшеном. Контракт зафиксирован после
interrupt-имплементации (см. [INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md)).
+1
View File
@@ -22,6 +22,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
@@ -7,9 +7,10 @@ import kotlinx.serialization.Serializable
* Запись в working memory диалога: ровно то, что агент сейчас видит в
* LLM-контексте. Упорядочено по `order_idx` (заполняется в store при append).
*
* Sealed-иерархия: для v1 — `System` (синтетический system-prompt),
* `User`/`Assistant` (реплики с ссылкой на audit log через [sourceMessageId]).
* Суммаризация (для v2) добавит подтип `Summary`.
* Sealed-иерархия: `User`/`Assistant` (реплики с ссылкой на audit log
* через [sourceMessageId]), `ToolExchange` (синтетическая запись об одном
* tool-вызове + его результате — для replay в LiteMessage(TOOL, ToolResult)
* при пересоздании LiteConv), `Summary` (суммаризация при compaction).
*/
@Serializable
sealed interface WorkingMemoryEntry {
@@ -17,13 +18,6 @@ sealed interface WorkingMemoryEntry {
/** Ссылка на исходное сообщение в audit log (`message.id`). `null` для синтетических строк. */
val sourceMessageId: String?
/** Синтетический system-prompt, добавляется при создании диалога. */
@Serializable
@SerialName("system")
data class System(val text: String) : WorkingMemoryEntry {
override val sourceMessageId: String? = null
}
/** Реплика пользователя. */
@Serializable
@SerialName("user")
@@ -47,6 +41,32 @@ sealed interface WorkingMemoryEntry {
val content: List<Content>,
) : WorkingMemoryEntry
/**
* Синтетический блок: один tool-вызов + его результат. Синтетический — потому
* что в audit log это две отдельные записи (`MessageRecord.ToolCall` +
* `MessageRecord.ToolResult`), а в working_memory мы храним одной строкой
* для удобства replay'а.
*
* При создании новой LiteConv каждая такая запись превращается в
* `LiteMessage(TOOL, [ToolResult(callId, name, response)])` — LiteRT-LM
* матчит по `name`, `callId` берётся из [sourceMessageId] (= id исходного
* [MessageRecord.ToolCall]). Если [wasCancelled] = true, [resultText]
* содержит маркер `[cancelled by user]` — модель видит честную причину
* отсутствия результата.
*
* [sourceMessageId] = id исходного [MessageRecord.ToolCall] (для трассировки
* в audit log).
*/
@Serializable
@SerialName("tool_exchange")
data class ToolExchange(
override val sourceMessageId: String,
val toolName: String,
val toolArgsJson: String,
val resultText: String,
val wasCancelled: Boolean = false,
) : WorkingMemoryEntry
/**
* Синтетический блок: суммаризация старых ходов, сгенерированная при
* compaction'е working memory. Не имеет ссылки на конкретное сообщение
@@ -59,7 +59,7 @@ class PayloadTest {
}
@Test
fun `legacy plain-array payload still decodes (backward compat)`() {
fun `legacy plain-array payload still decodes backward compat`() {
val legacy = "[" +
"""{"type":"text","body":"old message"}""" +
"]"
+55
View File
@@ -0,0 +1,55 @@
# `:storage-inmemory` — in-memory реализация `:storage-core` (KMP, jvm + native)
## Что это
In-memory реализация `MessageStore / WorkingMemoryStore /
ConversationStore / ReflectionStore`. Все структуры держит в
`ConcurrentHashMap` + `MutableList`, фолотится на RAM
(никаких файлов).
Решает: дешёвая тестовая среда без поднятия SQLite. Позволяет
прогонять `ChatAgentTest` за миллисекунды и держать сценарии
детерминированными.
## Где используется
- В тестах `:standalone` (`AbstractITTest`).
- В Android-имплементации (in-memory + Android-database микс).
- В любых юнит-тестах на агенте.
## Как подключить
```kotlin
commonMain.dependencies {
api("pw.binom.agentik:storage-inmemory:0.1.0")
api("pw.binom.agentik:storage-core:0.1.0")
}
val storage = InMemoryStorageSystem()
val messages: MessageStore = storage.messages
val working: WorkingMemoryStore = storage.working
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-storage-inmemory`.
## Тесты
```
./gradlew :storage-inmemory:allTests
```
Покрывают (через общие 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
fun `rename updates title and updatedAt, returns new updatedAt`() = runTest {
fun `rename updates title and updatedAt returns new updatedAt`() = runTest {
val store = InMemoryConversationStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.upsert(ConversationRecord("c1", null, false, t0, t0))
@@ -15,21 +15,19 @@ class InMemoryWorkingMemoryStoreTest {
fun `append assigns sequential order_idx starting from 0`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.System("you are brief"), t0)
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
val rows = store.list("c1")
assertEquals(3, rows.size)
assertEquals(listOf(0L, 1L, 2L), rows.map { it.orderIdx })
assertEquals(2, rows.size)
assertEquals(listOf(0L, 1L), rows.map { it.orderIdx })
}
@Test
fun `order_idx continues across conversations independently`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.System("a"), t0)
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
store.append("c2", WorkingMemoryEntry.System("b"), t0)
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
// c2 должен начать с 0, не продолжать c1
val rows2 = store.list("c2")
assertEquals(1, rows2.size)
@@ -56,8 +54,8 @@ class InMemoryWorkingMemoryStoreTest {
fun `clear removes all rows for a conversation`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.System("a"), t0)
store.append("c2", WorkingMemoryEntry.System("b"), t0)
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
store.clear("c1")
assertEquals(emptyList(), store.list("c1"))
assertEquals(1, store.list("c2").size)
@@ -67,23 +65,20 @@ class InMemoryWorkingMemoryStoreTest {
fun `compact without summary drops tail and returns new max`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.System("a"), t0)
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
// dropFromOrderIdx=2 → удаляет всё >= 2 (то есть только Assistant "a1")
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
assertEquals(1L, newMax)
// dropFromOrderIdx=1 → удаляет всё >= 1 (то есть только Assistant "a1")
val newMax = store.compact(dropFromOrderIdx = 1, conversationId = "c1", summaryText = null)
assertEquals(0L, newMax)
val rows = store.list("c1")
assertEquals(2, rows.size)
assertEquals("a", (rows[0].entry as WorkingMemoryEntry.System).text)
assertEquals("u1", rows[1].entry.sourceMessageId)
assertEquals(1, rows.size)
assertEquals("u1", rows[0].entry.sourceMessageId)
}
@Test
fun `compact with summary replaces tail with synthetic Summary row`() = runTest {
val store = InMemoryWorkingMemoryStore()
val t0 = Instant.parse("2026-09-15T10:00:00Z")
store.append("c1", WorkingMemoryEntry.System("you are brief"), t0)
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("how are you")), null), t0.plus(kotlin.time.Duration.parse("PT3S")))
+78
View File
@@ -0,0 +1,78 @@
# `:storage-sqlite` — SQLite реализация `:storage-core` (JVM-only)
## Что что это
Production persistence для `:standalone` на [SQLDelight](https://cashapp.github.io/sqldelight/):
- **messages** — append-only журнал с `conversation_id`, `created_at`.
- **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`) индексирован отдельно для быстрого
доступа.
Решает: стабильная, локальная, нулевая-настройка БД. Подходит и для
desktop-продакшена, и для Android, и для тестов (через Testcontainers).
## Где используется
- `:standalone` подключает по умолчанию (`storage.db` = путь из
`AGENTIK_DB_PATH`).
## Как подключить
```kotlin
jvmMain.dependencies {
implementation("pw.binom.agentik:storage-sqlite:0.1.0")
implementation("pw.binom.agentik:storage-core:0.1.0")
}
val storage = SqliteStorageSystem.open(Path("agentik.db"))
val messages: MessageStore = storage.messages
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-storage-sqlite`.
Зависит от `app.cash.sqldelight:sqlite-driver:2.1.0` (через
`gradle/libs.versions.toml`).
## Тесты
```
./gradlew :storage-sqlite:jvmTest
```
Покрывают: миграции (через `migrations/` каталог и SQLDelight
`*.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+.
@@ -72,9 +72,9 @@ class SqliteWorkingMemoryStore(private val db: AgentikDatabase) : WorkingMemoryS
}
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
is WorkingMemoryEntry.System -> "system"
is WorkingMemoryEntry.User -> "user"
is WorkingMemoryEntry.Assistant -> "assistant"
is WorkingMemoryEntry.ToolExchange -> "tool_exchange"
is WorkingMemoryEntry.Summary -> "summary"
}