29 Commits

Author SHA1 Message Date
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
59 changed files with 5780 additions and 803 deletions
+89
View File
@@ -0,0 +1,89 @@
# 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: Build :agentik-tui shadowJar
shell: bash
run: |
./gradlew :agentik-tui:shadowJar \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
test -f agentik-tui/build/libs/agentik-tui-all.jar \
&& echo "shadowJar OK: $(du -h agentik-tui/build/libs/agentik-tui-all.jar)"
- name: Upload shadowJars
uses: actions/upload-artifact@v4
with:
name: agentik-jars
path: |
standalone/build/libs/standalone-all.jar
agentik-cli/build/libs/agentik-cli-all.jar
agentik-tui/build/libs/agentik-tui-all.jar
if-no-files-found: error
retention-days: 7
+53 -40
View File
@@ -1,36 +1,31 @@
# Триггерится при публикации релиза в Gitea. Делает две вещи:
# 1. publish-libraries — публикует все KMP-библиотеки (jvm + все нативные таргеты)
# в домашний Nexus-репозиторий "caffeine" через subochev/devops/publish action.
# Переменные BINOM_REPO_URL / BINOM_REPO_USER / BINOM_REPO_PASSWORD задаются
# в Gitea Action Variables для репозитория (Settings → Actions → Variables).
# 2. build-standalone — собирает :standalone fatjar (shadowJar) и прикрепляет
# standalone-<version>-all.jar к release как downloadable asset.
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
#
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli, :agentik-tui)
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
# выполняется локально из исходников (или руками через `./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
steps:
- name: Checkout
uses: actions/checkout@v4
# agentik не использует Android-target ни в одном модуле (все KMP-таргеты
# JVM + native), поэтому Android SDK шаг из litert-kmp тут не нужен.
- name: Publish libraries
uses: https://git.binom.pw/subochev/devops/publish@main
with:
version: ${{ gitea.ref_name }}
build-standalone:
name: Build standalone fatjar
runs-on: ubuntu-latest
needs: publish-libraries
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -41,22 +36,40 @@ jobs:
java-version: '21'
distribution: 'adopt'
- name: Build shadowJar
shell: bash
run: ./gradlew :standalone:shadowJar -Dorg.gradle.jvmargs=-Xmx4096M --no-daemon --no-watch-fs --stacktrace
- name: Compute version for filename
id: ver
shell: bash
run: echo "version=${GITEA_REF_NAME}" >> "$GITEA_OUTPUT"
- name: Attach standalone jar to release
uses: https://github.com/softprops/action-gh-release@v2
- name: Gradle cache
uses: actions/cache@v4
with:
files: |
standalone/build/libs/standalone-*-all.jar
standalone/build/libs/standalone-*-sources.jar
fail_on_unmatched_files: false
generate_release_notes: false
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:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
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 |
Всё что помечено ✗ — нужно прогонять руками на реальном окружении.
+155
View File
@@ -1,2 +1,157 @@
# 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 REPL-клиент (JLine) к /agentik
├── agentik-tui/ Compose-for-Mosaic TUI-клиент (desktop) к /agentik
└── 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-all.jar
# TUI
java --enable-native-access=ALL-UNNAMED -jar agentik-tui-0.1.0-all.jar
# curl
curl http://localhost:8080/health
```
## Модули
- Запускаемые:
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
- [`:agentik-cli`](agentik-cli/README.md) — REPL-клиент (JLine).
- [`:agentik-tui`](agentik-tui/README.md) — Compose-for-Mosaic TUI.
- Библиотеки (контракты и реализации):
- [`: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,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) {
+84
View File
@@ -0,0 +1,84 @@
# `:agentik-cli` — JVM CLI клиент к `/agentik`
## Что это
JVM-only REPL-клиент к серверу `:standalone` через `:client`
над HTTP+SSE:
- Нативный REPL с JLine (стрелки влево/вправо/вверх, история,
Ctrl-D/E).
- Подписка на live-стрим событий агента.
- Slash-команды: `/new /list /switch /rename /rm /interrupt /history
/pwd /help /exit /quit`.
- Persistent session id в `~/.agentik/cli-state.json`.
Решает: быстрый способ проверить агента руками из терминала.
Используется в CI-смоук-тестах и для daily-driver.
## Как запустить
### Требования
- JVM 21+ (на машине должна быть JAVA_HOME или `java` в PATH).
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
### Запуск из готового fatjar
```bash
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-all.jar \
--server http://192.168.76.166:8080/agentik
```
### Запуск через Gradle (dev)
```bash
./gradlew :agentik-cli: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 и выходит |
## Slash-команды (внутри REPL)
| Команда | Синонимы | Что делает |
|---|---|---|
| `/help` | | Показывает help |
| `/new [title]` | | Создать диалог |
| `/list` | `/ls` | Список диалогов |
| `/switch <id>` | `/sw`, `/cd` | Переключиться на диалог |
| `/rename <title>` | | Переименовать текущий диалог |
| `/rm [id]` | `/delete` | Удалить (текущий или по id) |
| `/interrupt` | `/stop`, `/cancel` | Прервать текущий ход |
| `/history` | `/h`, `/hist` | Показывает историю текущего диалога |
| `/pwd` | | Путь к state-file |
| `/exit`, `/quit` | | Выйти |
## Переменные среды (пробрасываются серверу через `--server`)
См. [`../standalone/README.md`](../standalone/README.md). На стороне
клиента они **не** интерпретируются — это лишь настройки запуска
агента. CLI только знает, по какому URL стучаться.
## Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
default-таймауте Ktor. Используйте либо `ssh -tt`, либо нативный
terminal. Это upstream-особенность Ktor SSE.
## Тесты
```
./gradlew :agentik-cli:jvmTest
```
23 теста: парсер slash-команд, event-рендер, state-repository.
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
+102
View File
@@ -0,0 +1,102 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.gradle.api.artifacts.ConfigurationContainer
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.shadow)
}
kotlin {
jvmToolchain(21)
// Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9,
// но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть.
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto.
// commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only)
// и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual,
// расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
implementation(project(":proto"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
}
jvmMain.dependencies {
// :client JVM-only (ktor-cio). Подключаем только в jvmMain.
implementation(project(":client"))
// JLine для readline с историей и completion.
implementation(libs.jline)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
// runTest { } — suspend test runner для commonTest.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
}
jvmTest.dependencies {
// JUnit нужен в jvmTest — kotlin-test на JVM = JUnit4.
implementation("junit:junit:4.13.2")
}
}
@OptIn(ExperimentalKotlinGradlePluginApi::class)
jvm {
binaries {
executable {
mainClass.set("pw.binom.agentik.cli.MainKt")
}
}
}
}
// --- Fatjar (uberjar) ---
//
// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`.
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register.
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"))
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.cli.MainKt"
attributes["Implementation-Title"] = "agentik-cli"
attributes["Implementation-Version"] = project.version.toString()
}
includeEmptyDirs = false
}
@@ -0,0 +1,323 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import kotlin.time.Instant
/**
* Главный класс REPL.
*
* Управляет:
* - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]);
* - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer].
* - персистентностью сессии (восстановление последнего диалога при перезапуске CLI).
*
* Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения.
* `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий
* увидит [Event.Interrupted] и сам завершится.
*/
class AgentikCli internal constructor(private val config: CliConfig) {
private val agent: Agent = CliPlatform.openAgent(baseUrl = config.server, id = config.id)
private val terminal: CliTerminal = CliPlatform.openTerminal(
historyFile = if (config.historyEnabled) stateFilePath() else null,
prompt = "agentik> ",
)
private val sessionRepo = SessionRepository(
filePath = if (config.historyEnabled) stateFilePath() else null,
io = CliPlatform.sessionIo(),
)
private var currentConv: Conversation? = null
private var currentTitle: String? = null
private var lastEventAt: Instant = Instant.DISTANT_PAST
private val scope = CoroutineScope(Dispatchers.Default)
suspend fun run() {
try {
// Восстановление сессии.
val saved = sessionRepo.load()
if (saved != null) {
val conv = runCatching { agent.getConversation(saved.conversationId) }
.getOrNull()
if (conv != null) {
currentConv = conv
currentTitle = conv.title
lastEventAt = saved.lastEventAt
terminal.printSystem(
"восстановлен диалог ${shorten(conv.id)}" +
" (${conv.title ?: "без названия"})",
)
} else {
terminal.printSystem(
"прошлый диалог ${shorten(saved.conversationId)} больше не существует",
)
}
}
printBanner()
// Главный цикл.
while (scope.isActive) {
terminal.print(prompt())
val line = terminal.readLine() ?: break // EOF → выходим
val trimmed = line.trim()
if (trimmed.isEmpty()) continue
if (trimmed.startsWith("/")) {
when (val r = parseSlash(trimmed.substring(1))) {
is ParseResult.Success -> {
if (handleCommand(r.command) == CommandResult.Exit) break
}
is ParseResult.Failure -> terminal.printSystem(r.message)
}
} else {
handleUserMessage(trimmed)
}
}
} finally {
terminal.printSystem("до свидания.")
currentConv?.close()
terminal.close()
sessionRepo.close()
scope.cancel()
}
}
// ============================================================ banner / prompt
private suspend fun printBanner() {
terminal.println()
terminal.println("agentik-cli — id=${config.id} — type /help")
terminal.println("server: ${config.server}")
when (val c = currentConv) {
null -> terminal.println("диалог: не выбран — начните с /new или /switch <id>")
else -> terminal.println("диалог: ${shorten(c.id)} (${c.title ?: "без названия"})")
}
terminal.println()
}
private fun prompt(): String = "agentik${if (currentConv != null) "" else " (-)"}> "
private suspend fun printHelp() {
terminal.println(
"""
|Slash-команды:
| /help эта справка
| /new [title] создать новый диалог
| /list, /ls список диалогов (новые сверху)
| /switch <id>, /sw переключиться на диалог по id
| /rename <title> переименовать текущий диалог
| /delete [<id>], /rm удалить диалог (по id или текущий)
| /history, /h последние сообщения текущего диалога
| /interrupt, /stop прервать текущий ход
| /pwd показать текущий диалог
| /exit, /quit выйти (Ctrl-D тоже)
|
|Любой ввод без ведущего `/` отправляется агенту в текущий диалог.
""".trimMargin(),
)
}
// ============================================================ command dispatch
private suspend fun handleCommand(cmd: SlashCommand): CommandResult = when (cmd) {
SlashCommand.Help -> { printHelp(); CommandResult.Continue }
SlashCommand.Exit, SlashCommand.Quit -> CommandResult.Exit
is SlashCommand.New -> { handleNew(cmd.title); CommandResult.Continue }
SlashCommand.List -> { handleList(); CommandResult.Continue }
is SlashCommand.Switch -> { handleSwitch(cmd.id); CommandResult.Continue }
is SlashCommand.Rename -> { handleRename(cmd.title); CommandResult.Continue }
is SlashCommand.Delete -> { handleDelete(cmd.id); CommandResult.Continue }
SlashCommand.Interrupt -> { handleInterrupt(); CommandResult.Continue }
SlashCommand.History -> { handleHistory(); CommandResult.Continue }
SlashCommand.Pwd -> { handlePwd(); CommandResult.Continue }
}
private suspend fun handleNew(title: String?) {
val conv = agent.createConversation(temp = false)
if (title != null) conv.rename(title)
currentConv = conv
currentTitle = title ?: conv.title
lastEventAt = Instant.DISTANT_PAST
terminal.printSystem("создан диалог ${shorten(conv.id)}" + if (title != null) " — «$title»" else "")
sessionRepo.save(conv.id, lastEventAt)
}
private suspend fun handleList() {
terminal.println("диалоги (новые сверху):")
agent.getConversations(offset = 0).collect { conv ->
val marker = if (conv.id == currentConv?.id) "*" else " "
val title = conv.title ?: "(без названия)"
terminal.println(" $marker ${shorten(conv.id)} $title [${conv.updatedAt}]")
}
}
private suspend fun handleSwitch(id: String) {
val conv = agent.getConversation(id)
if (conv == null) {
terminal.printSystem("диалог $id не найден")
return
}
currentConv?.close()
currentConv = conv
currentTitle = conv.title
lastEventAt = Instant.DISTANT_PAST
sessionRepo.save(conv.id, lastEventAt)
terminal.printSystem("переключились на ${shorten(conv.id)} (${conv.title ?: "без названия"})")
}
private suspend fun handleRename(title: String) {
val c = currentConv ?: run {
terminal.printSystem("нет активного диалога — /new")
return
}
c.rename(title)
currentTitle = title
terminal.printSystem("заголовок: $title")
}
private suspend fun handleDelete(id: String?) {
val target = id ?: currentConv?.id
if (target == null) {
terminal.printSystem("нет диалога для удаления")
return
}
val ok = agent.deleteConversation(target)
if (ok) {
terminal.printSystem("удалён ${shorten(target)}")
if (target == currentConv?.id) {
currentConv?.close()
currentConv = null
currentTitle = null
sessionRepo.clear()
}
} else {
terminal.printSystem("диалог ${shorten(target)} не найден")
}
}
private suspend fun handleInterrupt() {
val c = currentConv ?: run {
terminal.printSystem("нет активного диалога")
return
}
c.interrupt()
terminal.printSystem("прерывание отправлено")
}
private suspend fun handlePwd() {
val c = currentConv ?: run {
terminal.printSystem("диалог: не выбран")
return
}
terminal.printSystem("id: ${c.id}")
terminal.printSystem("title: ${c.title ?: "—"}")
terminal.printSystem("updatedAt: ${c.updatedAt}")
terminal.printSystem("temporal: ${c.isTemporal}")
}
private suspend fun handleHistory() {
val c = currentConv ?: run {
terminal.printSystem("нет активного диалога")
return
}
terminal.println("история:")
c.getMessages(after = Instant.DISTANT_PAST).collect { msg -> renderHistoryMessage(msg) }
}
private suspend fun renderHistoryMessage(msg: Message) {
val prefix = " [${msg.date}] "
when (msg) {
is Message.UserMessage ->
terminal.println(prefix + "user | " + msg.content.text())
is Message.AssistantMessage ->
terminal.println(prefix + "agent | " + msg.content.text())
is Message.ToolCall ->
terminal.println(prefix + "tool>${msg.toolName} | ${msg.toolArgs.take(160)}")
is Message.ToolResult ->
terminal.println(prefix + "tool< | " + (msg.result?.take(160) ?: "null"))
is Message.Error ->
terminal.println(prefix + "<error${msg.code?.let { "/$it" } ?: ""}> ${msg.message}")
}
}
private fun List<Content>.text(): String =
joinToString(separator = "") { c ->
when (c) {
is Content.Text -> c.body
is Content.Image -> "[image:${c.mime}:${c.data.size}B]"
}
}
// ============================================================ user-message
private suspend fun handleUserMessage(text: String) {
val conv = currentConv ?: run {
terminal.printSystem("нет активного диалога — /new")
return
}
terminal.println() // пустая строка для визуального отделения блока
val renderer = EventRenderer(terminal)
val turnFinished = CompletableDeferred<Unit>()
// Подписчик events: принимает события и обновляет lastEventAt,
// по терминальному событию закрывает Deferred.
val eventsJob = scope.launch {
try {
conv.events(after = lastEventAt).collect { ev ->
renderer.render(ev)
if (ev.date > lastEventAt) {
lastEventAt = ev.date
sessionRepo.save(conv.id, lastEventAt)
}
if (ev is Event.End || ev is Event.Interrupted || ev is Event.Error) {
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
}
}
} catch (t: Throwable) {
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
if (t !is kotlinx.coroutines.CancellationException) {
terminal.printSystem("[events stream error] ${t.message}")
}
}
}
try {
conv.send(listOf(Content.Text(text)))
turnFinished.await()
} catch (t: Throwable) {
terminal.printSystem("[send error] ${t.message}")
} finally {
eventsJob.cancel()
renderer.close()
terminal.println()
}
}
// ============================================================ utils
private fun shorten(id: String): String = id.take(8)
private fun stateFilePath(): String? {
val home = CliPlatform.homeDir() ?: return null
val dir = "$home/.agentik"
return "$dir/cli-state.json"
}
}
private enum class CommandResult { Continue, Exit }
@@ -0,0 +1,52 @@
package pw.binom.agentik.cli
import pw.binom.agentik.proto.Agent
/**
* Платформенные зависимости CLI. Все вещи, требующие JVM-stdlib или
* нативных API (терминал, env, файловое IO для state-файла, HTTP-клиент),
* предоставляются здесь как `expect/actual`.
*
* Текущий статус: jvmMain полностью реализован (JLine + `java.io` + `:client`),
* nativeMain — заглушки (подключение native ktor-движков и termios — отдельная задача).
*/
expect object CliPlatform {
fun openAgent(baseUrl: String, id: String): Agent
fun openTerminal(
historyFile: String?,
prompt: String,
): CliTerminal
/** HOME/USERPROFILE для пути пути state-файла; null если недоступна. */
fun homeDir(): String?
/** Переменная среды (native API). Для jvmMain — `System.getenv`. */
fun env(key: String): String?
/** Файловое IO для session-state; nativeMain возвращает no-op. */
fun sessionIo(): SessionIo
}
/**
* Абстракция терминала, нужная для REPL. suspend-методы, чтобы не блокировать
* event-loop агентного цикла во время ожидания ввода.
*/
interface CliTerminal {
val prompt: String
/** Следующая строка пользователя (без prompt). null = EOF (Ctrl-D/Ctrl-Z). */
suspend fun readLine(): String?
/** Печатает строку + перевод строки. */
suspend fun println(text: String = "")
/** Печатает строку без перевода (для streamed chunks). */
suspend fun print(text: String)
/** Подсветить prompt (символы-разделители сообщений, системные баннеры и т.п.). */
suspend fun printSystem(text: String)
/** Закрыть терминал: restore raw mode, flush history file, ... */
fun close()
}
@@ -0,0 +1,82 @@
package pw.binom.agentik.cli
import pw.binom.agentik.proto.Event
/**
* Печатает [Event] в человеко-читаемом виде через [CliTerminal].
*
* Дизайн:
* - [Event.StartReasoning] — просто системный маркер; текст мысли НЕ выводим
* отдельным форматом (см. proto: reasonig текст идёт через [Event.AppendText]).
* - [Event.StartResponse] с `responseType=TEXT` — начало печати ответа; закрытие
* происходит при [Event.End] или [Event.Interrupted].
* - [Event.AppendText] — кусок текста, печатается БЕЗ перевода строки (чанки).
* - [Event.AppendImage] — выводим как `[image: <mime>, <bytes> bytes]` placeholder.
* Реальный рендеринг сделаем позже через iTerm/Kitty протоколы.
* - [Event.End] / [Event.Interrupted] — закрывают текущий блок.
* - [Event.Error] — отдельный системный блок `[error: …]`.
*/
class EventRenderer(private val terminal: CliTerminal) {
/** Трекает открыт ли сейчас «блок ответа» (после [Event.StartResponse], до [Event.End]). */
private var responseOpen = false
suspend fun render(event: Event) {
when (event) {
is Event.StartReasoning -> {
terminal.printSystem("…thinking…")
if (responseOpen) {
terminal.println()
responseOpen = false
}
}
is Event.StartResponse -> {
if (responseOpen) terminal.println()
responseOpen = true
// Без префикса — текст будет стримиться дальше через AppendText.
}
is Event.AppendText -> {
terminal.print(event.body)
}
is Event.AppendImage -> {
terminal.print("[image:${event.mime}:${event.body.size} bytes]")
}
is Event.Interrupted -> {
if (responseOpen) {
terminal.println()
terminal.printSystem("[interrupted]")
responseOpen = false
} else {
terminal.printSystem("[interrupted]")
}
}
is Event.End -> {
if (responseOpen) {
terminal.println()
responseOpen = false
}
}
is Event.Error -> {
terminal.println()
terminal.printSystem("[error${event.code?.let { "/$it" } ?: ""}] ${event.message}")
if (responseOpen) responseOpen = false
}
else -> {
// ToolCall/ToolResult — это «структура» диалога, в текстовом стриме
// не показываем; в веб-UI будет по-другому.
terminal.printSystem("[event:${event::class.simpleName}]")
}
}
}
fun close() {
responseOpen = false
}
}
@@ -0,0 +1,105 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.runBlocking
/**
* Точка входа CLI. Поддерживает аргументы командной строки:
*
* ```
* agentik-cli [--server URL] [--id ID] [--no-history] [--help]
*
* --server URL базовый URL сервера agentik (default $AGENTIK_SERVER или
* http://localhost:8080/agentik)
* --id ID идентификатор этого клиента (default "cli:$USER")
* --no-history не сохранять состояние в ~/.agentik/cli-state.json
* --help, -h распечатать usage и выйти
* ```
*
* Без аргументов — стартует REPL.
*/
fun main(args: Array<String>) = runBlocking {
val cfg = parseCliArgs(args)
if (cfg == null) {
printUsage()
return@runBlocking
}
AgentikCli(cfg).run()
}
/**
* Конфигурация CLI, вычисленная из аргументов + переменных среды.
* Доступна из других файлов commonMain (видна как `internal` внутри модуля).
*/
internal data class CliConfig(
val server: String,
val id: String,
val historyEnabled: Boolean,
)
private fun parseCliArgs(args: Array<String>): CliConfig? {
var server: String? = null
var id: String? = null
var historyEnabled = true
var i = 0
while (i < args.size) {
when (val a = args[i]) {
"--help", "-h", "help" -> return null
"--server", "-s" -> {
require(i + 1 < args.size) { "$a требует URL" }
server = args[i + 1]; i += 2
}
"--id" -> {
require(i + 1 < args.size) { "$a требует значение" }
id = args[i + 1]; i += 2
}
"--no-history" -> { historyEnabled = false; i++ }
"--" -> i++ // разделитель; остальное игнорируем
else -> error("неизвестный аргумент: $a (введите --help)")
}
}
val resolvedServer = server
?: CliPlatform.env("AGENTIK_SERVER")
?: "http://localhost:8080/agentik"
val resolvedId = id ?: "cli:${CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"}"
return CliConfig(
server = resolvedServer,
id = resolvedId,
historyEnabled = historyEnabled,
)
}
private fun printUsage() {
val defaultServer = CliPlatform.env("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
val defaultUser = CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"
println("""
agentik-cli — REPL поверх протокола agentik
Использование:
agentik-cli [--server URL] [--id ID] [--no-history]
Аргументы:
--server, -s URL базовый URL (default: $defaultServer)
--id ID идентификатор клиента (default: cli:${defaultUser})
--no-history не сохранять состояние в ~/.agentik/cli-state.json
--help, -h эта справка
Переменные среды:
AGENTIK_SERVER базовый URL агента (используется если --server не задан)
HOME для пути ~/.agentik/cli-state.json
В REPL:
/help список slash-команд
/new [title] создать диалог (title опционально)
/list, /ls список диалогов
/switch <id>, /sw <id> переключиться на диалог
/rename <title> переименовать текущий диалог
/delete [<id>], /rm удалить (по id или текущий)
/history, /h последние сообщения текущего диалога
/interrupt, /stop прервать текущий ход
/pwd показать текущий диалог
/exit, /quit выйти (Ctrl-D тоже работает)
""".trimIndent())
}
@@ -0,0 +1,81 @@
package pw.binom.agentik.cli
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.time.Instant
/**
* Состояние CLI между запусками: последний выбранный диалог и момент последнего
* увиденного [Event.date] в его потоке (для корректного `events(after)` после рестарта).
*
* Доступ к диску инкапсулирован в платформенный [CliPlatform] — commonMain ничего
* не знает про `java.io.File`/`NSFileManager`, чтобы KMP-сборка собиралась
* под все цели. Файл: `$HOME/.agentik/cli-state.json`.
*/
internal class SessionRepository internal constructor(
private val filePath: String?,
private val io: SessionIo,
) {
@Serializable
private data class State(
val conversationId: String,
val lastEventAt: String,
)
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
/** Открывается ленивым чтением. [save] ещё не было — файл может отсутствовать. */
private var cached: State? = null
fun load(): SavedSession? {
val path = filePath ?: return null
val raw = io.readAll(path) ?: return null
return runCatching {
val state = json.decodeFromString(State.serializer(), raw)
cached = state
SavedSession(
conversationId = state.conversationId,
lastEventAt = Instant.parse(state.lastEventAt),
)
}.getOrNull()
}
fun save(conversationId: String, lastEventAt: Instant) {
val path = filePath ?: return
val state = State(
conversationId = conversationId,
lastEventAt = lastEventAt.toString(),
)
cached = state
val body = json.encodeToString(State.serializer(), state)
io.writeAtomic(path, body)
}
fun clear() {
val path = filePath ?: return
io.delete(path)
cached = null
}
fun close() {
// для совместимости с будущим in-memory state; пока no-op
}
}
internal data class SavedSession(
val conversationId: String,
val lastEventAt: Instant,
)
/**
* Минимальный платформо-зависимый IO-интерфейс для одного файла. Реализации
* в jvmMain (`java.io.File` + atomic `tmp → rename`) и в nativeMain (пока no-op-stub).
*
* public, потому что его возвращает public [CliPlatform.sessionIo].
*/
interface SessionIo {
fun readAll(path: String): String?
fun writeAtomic(path: String, body: String)
fun delete(path: String)
}
@@ -0,0 +1,90 @@
package pw.binom.agentik.cli
/**
* Slash-команды REPL'а. Первая буква `/` не хранится — парсер уже её отрезал.
*
* Свободный ввод (без `/` в начале) — это сообщение пользователя агенту в
* текущий диалог и НЕ разбирается в [parse].
*/
sealed interface SlashCommand {
data object Help : SlashCommand
data object Exit : SlashCommand
data object Quit : SlashCommand // синоним Exit
/** Создать новый диалог; опционально — заголовок. */
data class New(val title: String?) : SlashCommand
/** Список диалогов (cold flow — печатаем по мере прихода страниц). */
data object List : SlashCommand
/** Подключиться к существующему диалогу по id. */
data class Switch(val id: String) : SlashCommand
/** Переименовать текущий диалог. */
data class Rename(val title: String) : SlashCommand
/** Удалить диалог (по id или текущий). */
data class Delete(val id: String?) : SlashCommand
/** Прервать текущий ход. no-op если хода нет. */
data object Interrupt : SlashCommand
/** Показать последние сообщения текущего диалога (cold flow). */
data object History : SlashCommand
/** Показать информацию о текущем диалоге. */
data object Pwd : SlashCommand
}
/**
* Парсит строку (без ведущего `/`) в [SlashCommand] либо возвращает [Result.Failure]
* с сообщением об ошибке.
*
* Команды нечувствительны к регистру (команда `/LIST` == `/list`).
*/
fun parseSlash(input: String): ParseResult {
val s = input.trim()
if (s.isEmpty()) return ParseResult.Failure("пустая команда (введите /help)")
// Разбиваем на команду и её аргументы. Поддерживаем склейку: /new foo bar → new "foo bar"
val firstSpace = s.indexOfAny(charArrayOf(' ', '\t'))
val cmd = if (firstSpace < 0) s else s.substring(0, firstSpace)
val rest = if (firstSpace < 0) "" else s.substring(firstSpace + 1).trim()
val args = if (rest.isEmpty()) emptyList() else rest.split(' ').filter { it.isNotEmpty() }
val command: SlashCommand? = when (cmd.lowercase()) {
"help", "?" -> SlashCommand.Help
"exit" -> SlashCommand.Exit
"quit", "q" -> SlashCommand.Quit
"new" -> SlashCommand.New(rest.takeIf { it.isNotEmpty() })
"list", "ls" -> SlashCommand.List
"switch", "sw", "cd" -> args.firstOrNull()?.let { SlashCommand.Switch(it) }
"rename", "mv", "title" -> rest.takeIf { it.isNotEmpty() }?.let { SlashCommand.Rename(it) }
"delete", "rm" -> SlashCommand.Delete(args.firstOrNull())
"interrupt", "stop", "cancel" -> SlashCommand.Interrupt
"history", "hist", "h" -> SlashCommand.History
"pwd", "where" -> SlashCommand.Pwd
else -> null
}
if (command != null) return ParseResult.Success(command)
// Не нашли команду: либо неизвестная, либо не хватает аргумента.
val cmdLower = cmd.lowercase()
return when (cmdLower) {
"switch", "sw", "cd" -> ParseResult.Failure("укажите id диалога: /switch <id>")
"rename", "mv", "title" -> ParseResult.Failure("укажите заголовок: /rename <title>")
else -> ParseResult.Failure("неизвестная команда: /$cmd (введите /help)")
}
}
sealed interface ParseResult {
data class Success(val command: SlashCommand) : ParseResult
data class Failure(val message: String) : ParseResult
}
/** Удобный helper для тестов и общего кода. */
fun parseSlashOrNull(input: String): SlashCommand? =
when (val r = parseSlash(input)) {
is ParseResult.Success -> r.command
is ParseResult.Failure -> null
}
@@ -0,0 +1,81 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Подменяем [CliTerminal] простой in-memory реализацией и проверяем,
* что события рендерятся в правильном формате.
*/
class EventRendererTest {
private class FakeTerminal : CliTerminal {
override val prompt: String = ">"
val out = StringBuilder()
override suspend fun readLine(): String? = null
override suspend fun println(text: String) { out.appendLine(text) }
override suspend fun print(text: String) { out.append(text) }
override suspend fun printSystem(text: String) { out.appendLine("· $text") }
override fun close() {}
fun text() = out.toString()
}
@Test
fun `simple response stream`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
r.render(Event.AppendText(Instant.DISTANT_PAST, "Привет"))
r.render(Event.AppendText(Instant.DISTANT_PAST, ", мир!"))
r.render(Event.End(Instant.DISTANT_PAST))
// StartResponse открывает блок, AppendText без \n, End закрывает \n
val text = t.text()
assertTrue(text.contains("Привет, мир!"), "got: $text")
// после End должен быть перевод строки
assertTrue(text.endsWith("\n"))
}
@Test
fun `interrupted closes block`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
r.render(Event.AppendText(Instant.DISTANT_PAST, "Частично"))
r.render(Event.Interrupted(Instant.DISTANT_PAST))
val text = t.text()
assertTrue(text.contains("Частично"))
assertTrue(text.contains("· [interrupted]"))
}
@Test
fun `error before response`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.Error(Instant.DISTANT_PAST, message = "что-то сломалось", code = "500"))
val text = t.text()
assertTrue(text.contains("· [error/500] что-то сломалось"))
}
@Test
fun `start_reasoning is printed as system line`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartReasoning(Instant.DISTANT_PAST))
assertTrue(t.text().contains("· …thinking…"))
}
@Test
fun `image append renders placeholder`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.IMAGE))
r.render(Event.AppendImage(Instant.DISTANT_PAST, body = ByteArray(64), mime = "image/png"))
r.render(Event.End(Instant.DISTANT_PAST))
assertTrue(t.text().contains("[image:image/png:64 bytes]"))
}
}
@@ -0,0 +1,101 @@
package pw.binom.agentik.cli
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class SlashCommandTest {
@Test
fun `help is parsed`() {
assertIs<SlashCommand.Help>(parseSlashOrNull("help"))
assertIs<SlashCommand.Help>(parseSlashOrNull("?"))
assertIs<SlashCommand.Help>(parseSlashOrNull("HELP"))
}
@Test
fun `exit and quit alias`() {
assertIs<SlashCommand.Exit>(parseSlashOrNull("exit"))
assertIs<SlashCommand.Quit>(parseSlashOrNull("q"))
assertIs<SlashCommand.Quit>(parseSlashOrNull("Quit"))
}
@Test
fun `new without title`() {
assertIs<SlashCommand.New>(parseSlashOrNull("new")).let {
assertEquals(null, it.title)
}
}
@Test
fun `new with multi-word title`() {
val cmd = parseSlashOrNull("new my cool chat")
assertIs<SlashCommand.New>(cmd)
assertEquals("my cool chat", cmd.title)
}
@Test
fun `switch requires id`() {
val r = parseSlash("sw")
assertIs<ParseResult.Failure>(r)
}
@Test
fun `switch with id`() {
val cmd = parseSlashOrNull("switch abc123")
assertIs<SlashCommand.Switch>(cmd)
assertEquals("abc123", cmd.id)
}
@Test
fun `rename requires title`() {
val r = parseSlash("rename")
assertIs<ParseResult.Failure>(r)
// А "rename " (с пробелом, но без слов после) — это уже успех с пустым title?
// У нас: rest = "" → takeIf { it.isNotEmpty() } → null → Failure. ОК.
}
@Test
fun `rename with title`() {
val cmd = parseSlashOrNull("rename my new title ")
assertIs<SlashCommand.Rename>(cmd)
assertEquals("my new title", cmd.title) // trim() делает своё
}
@Test
fun `delete may have id or not`() {
assertIs<SlashCommand.Delete>(parseSlashOrNull("rm")).let {
assertEquals(null, it.id)
}
assertIs<SlashCommand.Delete>(parseSlashOrNull("delete abc")).let {
assertEquals("abc", it.id)
}
}
@Test
fun `unknown command fails`() {
val r = parseSlash("foobar")
assertIs<ParseResult.Failure>(r)
}
@Test
fun `empty command fails`() {
val r = parseSlash("")
assertIs<ParseResult.Failure>(r)
}
@Test
fun `command is case insensitive`() {
assertIs<SlashCommand.List>(parseSlashOrNull("LIST"))
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("STOP"))
assertIs<SlashCommand.Pwd>(parseSlashOrNull("PWD"))
}
@Test
fun `interrupt synonyms`() {
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("interrupt"))
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("stop"))
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("cancel"))
}
}
@@ -0,0 +1,151 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jline.reader.EndOfFileException
import org.jline.reader.LineReader
import org.jline.reader.LineReaderBuilder
import org.jline.reader.UserInterruptException
import org.jline.terminal.TerminalBuilder
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
actual object CliPlatform {
actual fun openAgent(baseUrl: String, id: String): Agent =
AgentikAgent(id = id, baseUrl = baseUrl)
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
JLineTerminal(historyFile = historyFile, prompt = prompt)
actual fun homeDir(): String? =
System.getenv("HOME") ?: System.getenv("USERPROFILE")
actual fun env(key: String): String? = System.getenv(key)
actual fun sessionIo(): SessionIo = JvmSessionIo
}
/**
* Реализация [SessionIo] поверх `java.io.File` + atomic `tmp → rename`.
* tmp-файл пишется в той же директории, что и целевой, чтобы rename
* был атомарным в рамках одного раздела (POSIX rename(2) и Windows
* MoveFileEx — атомарны внутри одного тома).
*/
private object JvmSessionIo : SessionIo {
override fun readAll(path: String): String? {
val f = File(path)
if (!f.exists()) return null
return runCatching { f.readText() }.getOrNull()
}
override fun writeAtomic(path: String, body: String) {
val target = File(path)
target.parentFile?.mkdirs()
val tmp = File(path + ".tmp")
tmp.writeText(body)
if (!tmp.renameTo(target)) {
// fallback: Windows-специфика — renameTo может не перезаписать существующий.
runCatching { Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) }
.getOrElse { target.writeText(tmp.readText()); tmp.delete() }
}
} override fun delete(path: String) {
runCatching { File(path).delete() }
}
}
/**
* Реализация [CliTerminal] поверх JLine ([LineReader]).
*
* JLine-3 API:
* - [TerminalBuilder.builder().system(true).build()] — открыть системный TTY.
* - [LineReader] поверх Terminal — readline-редактор (стрелки, history, Ctrl-A/E).
* - [LineReader.readLine(prompt)] — suspend-free, блокирующий IO; мы оборачиваем
* в [withContext] [Dispatchers.IO], чтобы не держать event-loop.
* - [DefaultHistory] (org.jline.reader.history.DefaultHistory) + история из файла.
*/
private class JLineTerminal(
historyFile: String?,
override val prompt: String,
) : CliTerminal {
private val terminal = TerminalBuilder.builder()
.system(true)
.jna(true)
.build()
private val historyImpl: org.jline.reader.History? = run {
if (historyFile == null) null else try {
val history = org.jline.reader.impl.history.DefaultHistory()
val histFile = File(historyFile)
histFile.parentFile?.mkdirs()
history.load()
if (histFile.exists()) {
history.append(histFile.toPath(), true)
}
history
} catch (t: Throwable) {
null
}
}
private val reader: LineReader = LineReaderBuilder.builder()
.terminal(terminal)
.apply { if (historyImpl != null) history(historyImpl) }
.build()
private val historyFilePath: java.nio.file.Path? =
historyFile?.let { File(it).toPath() }
override suspend fun readLine(): String? = withContext(Dispatchers.IO) {
try {
val line = reader.readLine(prompt)
// Сохраняем history при каждой строке — дешево, и при Ctrl-D / Ctrl-C
// ничего не теряется.
flushHistory()
line
} catch (_: UserInterruptException) {
// Ctrl-C: трактуем как «всё, выходим», как и EOF.
flushHistory()
null
} catch (_: EndOfFileException) {
// Ctrl-D на пустой строке.
flushHistory()
null
}
}
override suspend fun println(text: String): Unit = withContext(Dispatchers.IO) {
terminal.writer().println(text)
terminal.writer().flush()
}
override suspend fun print(text: String): Unit = withContext(Dispatchers.IO) {
terminal.writer().print(text)
terminal.writer().flush()
}
override suspend fun printSystem(text: String): Unit = withContext(Dispatchers.IO) {
terminal.writer().println("· $text")
terminal.writer().flush()
}
private fun flushHistory() {
val hf = historyFilePath ?: return
val h = historyImpl ?: return
runCatching {
h.save()
if (!h.isEmpty) {
// читаем из .tmp и дописываем
h.append(hf, true)
}
}
}
override fun close() {
runCatching { flushHistory() }
runCatching { terminal.close() }
}
}
@@ -0,0 +1,108 @@
package pw.binom.agentik.cli
import org.junit.After
import org.junit.Before
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Интеграционный тест на реальном временном файле. Только JVM: использует
* [java.io.File] для IO-интерфейса. На native-таргетах тест не собирается —
* TODO: переписать на kotlinx-io Files и перенести в commonTest.
*/
class SessionRepositoryTest {
private lateinit var tmp: File
@Before
fun setUp() {
tmp = File.createTempFile("agentik-cli-state", ".json")
tmp.delete()
}
@After
fun tearDown() {
if (tmp.exists()) tmp.delete()
File(tmp.path + ".tmp").delete()
}
@Test
fun `load returns null when file missing`() {
val repo = SessionRepository(tmp.path, JvmIo)
assertNull(repo.load())
}
@Test
fun `save then load roundtrip`() {
val repo = SessionRepository(tmp.path, JvmIo)
val savedAt = Instant.parse("2026-09-16T10:00:00Z")
repo.save(conversationId = "abcd-1234", lastEventAt = savedAt)
repo.close()
val repo2 = SessionRepository(tmp.path, JvmIo)
val restored = repo2.load()
assertNotNull(restored)
assertEquals("abcd-1234", restored.conversationId)
assertEquals(savedAt, restored.lastEventAt)
}
@Test
fun `save overwrites previous state`() {
val repo = SessionRepository(tmp.path, JvmIo)
repo.save("conv-1", Instant.parse("2026-09-16T10:00:00Z"))
repo.save("conv-2", Instant.parse("2026-09-16T11:00:00Z"))
repo.close()
val restored = SessionRepository(tmp.path, JvmIo).load()
assertNotNull(restored)
assertEquals("conv-2", restored.conversationId)
}
@Test
fun `null filepath means no-op`() {
val repo = SessionRepository(null, JvmIo)
repo.save("conv-X", Instant.parse("2026-09-16T10:00:00Z"))
// Не должно ни читать, ни писать.
assertNull(repo.load())
}
@Test
fun `clear deletes file`() {
val repo = SessionRepository(tmp.path, JvmIo)
repo.save("conv-Z", Instant.parse("2026-09-16T10:00:00Z"))
repo.close()
assertTrue(tmp.exists())
val repo2 = SessionRepository(tmp.path, JvmIo)
repo2.clear()
assertTrue(!tmp.exists())
}
@Test
fun `corrupt json is ignored (does not throw)`() {
File(tmp.path).writeText("this is not json")
val repo = SessionRepository(tmp.path, JvmIo)
assertNull(repo.load())
}
}
// JVM-only helper: реализация [SessionIo] поверх `java.io.File` для теста.
// В продакшен-коде на jvmMain ровно такая же логика.
private object JvmIo : SessionIo {
override fun readAll(path: String): String? {
val f = File(path); if (!f.exists()) return null
return runCatching { f.readText() }.getOrNull()
}
override fun writeAtomic(path: String, body: String) {
val target = File(path); target.parentFile?.mkdirs()
val tmp = File(path + ".tmp")
tmp.writeText(body)
if (!tmp.renameTo(target)) target.writeText(tmp.readText()).also { tmp.delete() }
}
override fun delete(path: String) { File(path).delete() }
}
@@ -0,0 +1,36 @@
package pw.binom.agentik.cli
import pw.binom.agentik.proto.Agent
/**
* Платформо-зависимая реализация для native-целей.
*
* Текущий статус: stub. native HTTP требует подключения ktor-client-core +
* платформенных engine'ов (ktor-client-darwin для Apple, ktor-client-curl для
* linux/mingw, ktor-client-okhttp для Android в перспективе) и переиспользования
* уже существующего `:client` SSE-парсера. Native readline требует termios
* через `kotlinx.cinterop` — добавим, когда дойдут руки.
*
* Пока запустить агента из native-бинаря CLI нельзя, но проект компилируется
* под все 8 KMP-целей — структурная готовность соблюдена.
*/
actual object CliPlatform {
actual fun openAgent(baseUrl: String, id: String): Agent =
error("agentik-cli native target is not implemented yet (baseUrl=$baseUrl)")
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
error("agentik-cli native target is not implemented yet (prompt=$prompt)")
actual fun homeDir(): String? = null
actual fun env(key: String): String? = null
actual fun sessionIo(): SessionIo = NoopSessionIo
}
/** Минимальный no-op-IO для native-целей пока не подключён реальный движок. */
private object NoopSessionIo : SessionIo {
override fun readAll(path: String): String? = null
override fun writeAtomic(path: String, body: String) {}
override fun delete(path: String) {}
}
+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()`).
+87
View File
@@ -0,0 +1,87 @@
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()
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)
}
jvmMain.dependencies {
implementation(project(":client"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
}
}
@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,154 @@
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.KeyEvent
import com.jakewharton.mosaic.layout.drawBehind
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Box
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Row
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.TextStyle
/**
* Корневая Compose-композиция TUI.
*
* Layout (минимальный):
* ```
* ┌─────────────────────────────────────────────────────────┐
* │ HEADER: agentik · id · conv-id · focus=… │
* ├─────────────────────────────────────────────────────────┤
* │ HISTORY (весь актуальный диалог) │
* ├─────────────────────────────────────────────────────────┤
* │ INPUT LINE: > text| │
* ├─────────────────────────────────────────────────────────┤
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
* └─────────────────────────────────────────────────────────┘
* ```
*/
@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(state, showHelp)
}
}
if (showHelp) HelpOverlay()
}
@Composable
private fun Header(state: AppState, focusIndex: Int) {
val title by state.currentTitle.collectAsState()
val convId by state.currentConversationId.collectAsState()
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
val titleStr = title ?: "(нет диалога)"
Text(
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
textStyle = TextStyle.Bold + TextStyle.Invert,
)
}
@Composable
private fun HistoryPanel(state: AppState) {
val messages by state.messages.collectAsState()
val scroll by state.historyScroll.collectAsState()
val rendered = if (messages.isEmpty()) {
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
} else {
messages.joinToString("") { renderMessage(it) }
}
Text(value = rendered)
}
private fun renderMessage(m: TuiMessage): String = when (m) {
is TuiMessage.System -> " ── ${m.text}\n"
is TuiMessage.User -> " > ${m.text}\n"
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
}
@Composable
private fun InputLine(state: AppState) {
val text by state.input.collectAsState()
val cursor by state.cursor.collectAsState()
val streaming by state.streaming.collectAsState()
val cursorPos = cursor.coerceIn(0, text.length)
val before = text.substring(0, cursorPos)
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
val after = text.substring(afterStart.coerceAtMost(text.length))
val prompt = if (streaming) " ⋯" else " >"
Text(
value = "$prompt $before|$cursorChar|${after}",
modifier = Modifier
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
.drawBehind {
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
state.input.let { /* touch */ }
},
)
}
private fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
if (ev.alt || ev.ctrl) return false
return when (ev.key) {
"Enter" -> state.submitInput() != null
"Backspace" -> { state.inputBackspace(); true }
"Delete" -> { state.inputDelete(); true }
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
"Home" -> { state.inputCursorHome(); true }
"End" -> { state.inputCursorEnd(); true }
else -> {
val s = ev.key
if (s.length == 1) { state.inputInsert(s); true }
else false
}
}
}
@Composable
private fun Footer(state: AppState, showHelp: Boolean) {
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
Text(value = hint, textStyle = TextStyle.Italic)
}
@Composable
private fun HelpOverlay() {
Column(modifier = Modifier) {
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
Text(value = " ↑ / ↓ скролл истории / курсор в input")
Text(value = " ← / → курсор в input")
Text(value = " Enter отправить сообщение")
Text(value = " Backspace / Del удалить символ")
Text(value = " Esc очистить input")
Text(value = " Ctrl-D / Ctrl-C выход")
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
}
}
@@ -0,0 +1,160 @@
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) {
/** Зона фокуса: 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
return text
}
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,97 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.runBlocking
/**
* Точка входа TUI-клиента agentik.
*
* ```
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
* ```
*
* Без аргументов — стартует Compose-Mosaic UI.
*/
fun main(args: Array<String>) = runBlocking {
val cfg = parseCliArgs(args) ?: run {
printUsage()
return@runBlocking
}
TuiApp(cfg).run()
}
/**
* Конфигурация 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?
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 эта справка
В UI:
Tab / Shift-Tab переключить фокус между историей и вводом
↑ / ↓ скроллить историю / двигать курсор в инпуте
← / → двинуть курсор в инпуте
Enter отправить сообщение
Ctrl-C / Ctrl-D выйти
F1 показать подсказки по горячим клавишам
""".trimIndent())
}
@@ -0,0 +1,28 @@
package pw.binom.agentik.tui
import androidx.compose.runtime.remember
import com.jakewharton.mosaic.runMosaicBlocking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import pw.binom.agentik.proto.Agent
import kotlin.coroutines.CoroutineContext
/**
* Корневая точка запуска UI. Стартует Mosaic-рантайм и ждёт завершения приложения.
*
* По дизайну — singleton: все остальные модули (UI, бэкенд-корутины) живут внутри
* одной Compose-композиции и пользуются её [CoroutineScope].
*
* Реальный бэкенд (TuiBackend) подключается в следующем коммите: сейчас
* стартует на пустом [Agent]-заглушке для smoke-теста.
*/
internal class TuiApp(private val config: TuiConfig) {
fun run() {
runMosaicBlocking {
val state = remember { AppState(config) }
App(state = state)
}
}
}
@@ -0,0 +1,14 @@
package pw.binom.agentik.tui
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent
/**
* Платформенная фабрика [Agent]. JVM-only пока: native не подключали ktor-движки.
*/
internal actual fun platformEnv(key: String): String? = System.getenv(key)
/**
* Реализация [TuiApp.createAgent] для JVM — обычный ktor-cio через `:client`.
*/
internal fun jvmCreateAgent(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 fun nativeCreateAgent(baseUrl: String, id: String): Agent =
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
+80 -14
View File
@@ -7,22 +7,78 @@ plugins {
group = "pw.binom.agentik"
// Publication version: -Pversion=<tag> (CICD publishes by release tag).
// Без явного -Pversion берётся fallback из gradle.properties или "0.1.0".
if (version == "unspecified") {
version = providers.gradleProperty("version").getOrElse("0.1.0")
}
// projectVersion определяется ниже как val, чтобы subprojects могли его
// прочитать через rootProject.extra["projectVersion"].
// Home Nexus URL/creds — Gitea action-variables BINOM_REPO_* (subochev/devops/publish).
// Локально для дебага: ./gradlew publish \
// -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
val binomRepoUrl = (findProperty("binom.repo.url") ?: "http://nexus.xx/repository/caffeine/").toString()
// 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-клиент (desktop, без iOS) с клавиатурной навигацией без ':'-префиксов.",
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
)
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
subprojects {
group = rootProject.group
version = rootProject.version
// 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")
@@ -40,13 +96,23 @@ subprojects {
}
// 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 = providers.provider {
project.findProperty("description")?.toString()
?: "agentik: ${project.name} (pw.binom.agentik)"
}
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
?: "agentik module: ${project.name}"
url = "https://git.binom.pw/subochev/agentik"
licenses {
+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-tui` — Compose-for-Mosaic клиент.
- Любой внешний 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.
@@ -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())
+6 -2
View File
@@ -1,5 +1,9 @@
# Default version for local builds; overridden by `-Pversion=<tag>` from CI/CD.
version=0.1.0
# 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
+19
View File
@@ -13,11 +13,16 @@ jvector = "3.0.6"
text-embedding-kmp = "3.0.0-SNAPSHOT"
kotlin-logging = "3.0.5"
logback = "1.5.18"
jline = "3.30.0"
mosaic = "0.18.0"
[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" }
@@ -56,6 +61,20 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
# --- Model Context Protocol (MCP) ---
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
# --- CLI: JLine (readline для JVM-таргета) ---
jline = { module = "org.jline:jline", version.ref = "jline" }
# --- 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`, `:agentik-tui` — оба работают поверх `:client`,
а следовательно поверх `:proto`.
- `: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`, `:agentik-tui`, или
внешние 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-дискриминаторами и строго обратно
совместимо.
+6
View File
@@ -36,6 +36,12 @@ 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")
// Встраиваемая долговременная память агента. `: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 можно без поломок (новые поля игнорируются).
+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)).
@@ -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"
}