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

Локальная проверка:
  ./gradlew publishToMavenLocal — все 11 модулей × 9-10 таргетов
  ./gradlew jvmTest — все тесты зелёные
  ./gradlew :standalone:shadowJar — 240MB fatjar, Main-Class загружается
2026-09-15 21:58:56 +03:00
subochev 21bb9e6f8f standalone: убрать demo-toolset после e2e-проверки
Удаляю DemoToolset.kt, поле AgentikConfig.demoToolset и парсер
AGENTIK_DEMO_TOOLSET — после ночной e2e-проверки toolsets
они больше не нужны в проде. Механика покрыта ChatAgentToolsetsTest
через stub-тулы inline. Если потребуется live e2e — проще
прокинуть свой ToolsetContribution-список из main/test.
2026-09-15 21:10:52 +03:00
subochev 1441b7da9f standalone: AGENTIK_DEMO_TOOLSET=1 + DemoToolset для e2e проверки mechanics 2026-09-15 15:52:05 +03:00
subochev 9ee942428d standalone: ChatAgent принимает StorageBundle вместо SqliteStores
Финальный swap — ChatAgent/ChatConversation теперь работают через абстрактный
StorageBundle (pw.binom.agentik.storage), а не через конкретный SqliteStores.
Подготовка к Android-портированию (там будет :storage-android вместо
:storage-sqlite).

Изменения:
- SqliteStores.asBundle() — convenience для превращения конкретного
  SQLite-импла в StorageBundle
- ChatAgent(private val storage: StorageBundle) — было stores: SqliteStores
- ChatConversation(private val storage: StorageBundle) — то же
- Main.kt, DebugRoutes.kt — вызовы обновлены, используется .asBundle()
- Все 5 тестовых файлов с ChatAgent(... stores = ...) — обновлены на
  ChatAgent(... storage = ...asBundle())
- StorageBundle : AutoCloseable — закрывает все 4 store'а; в тестах
  tearDown { storage.close() }

Конфиг не менялся: toolsets остаётся emptyList() по умолчанию (полная
невидимость механики тулсетов для модели). Подключение тулсетов — opt-in
через параметр ChatAgent(toolsets = ...) для будущего e2e-теста в post-implementation.

Tests: 340/340 green. Fatjar 240 MB. Без регрессий.
2026-09-15 15:25:36 +03:00
subochev 1dc5552f98 agent-toolsets: SystemPromptToolsetSection + интеграция в ChatAgent
Добавлен SystemPromptToolsetSection — рендер markdown-секции для system prompt.
Контракт:
- toolsets пустой → null (секция не добавляется, агент не знает о механике)
- иначе → краткое описание концепции + список 'name — description' для
  активных и неактивных (одинаковый формат per design contract)
- auto-activation НЕ упоминается в промпте (только в dispatch)

Интеграция в ChatAgent:
- Добавлен параметр toolsets: List<ToolsetContribution> = emptyList()
- При пустом списке — enable_toolset/disable_toolset НЕ регистрируются,
  секция в system prompt НЕ появляется (полная невидимость per A1-α)
- При непустом — тулы регистрируются, секция добавляется
- ToolsetRegistry + ToolsetDispatchPolicy создаются per-agent (один реестр
  на все диалоги — состояние 'активные тулсеты' общее)

Интеграция в ChatConversation:
- Новый параметр toolsetDispatch: ToolsetDispatchPolicy? = null
- runToolAndPersist: если задан — вызов идёт через policy (auto-activate
  неактивных тулсетов, fallback в base dispatcher для плоских тулов)
- Иначе — старое поведение через toolsByName

Тесты:
- 7 новых в :agent-toolsets (SystemPromptToolsetSection): пустые списки,
  только активные, только неактивные, оба, проверка отсутствия auto-activation
  упоминания, registry-based рендер, пустой реестр
- 5 новых в :standalone (ChatAgentToolsetsTest): default (пустой) — нет
  тулов и секции; non-empty — тулы и секция есть; enable_toolset активирует;
  вызов тула из неактивного тулсета — auto-activate; disable_toolset
  снимает из active set (но auto-activate на следующем вызове — by design)

Tests: 340/340 green (335 ранее + 5 новых ChatAgent integration)
2026-09-15 15:06:23 +03:00
subochev 2e1387273a agent-toolsets: ядро механики toolsets (реестр, диспетчер, встроенные тулы)
Новый KMP-модуль :agent-toolsets с основными абстракциями для тулсетов:

- ToolsetContribution(name, description, tools: List<ToolEntry>) — декларация
  тулсета: имя + описание + список входящих LiteTool'ов с именами.
- ToolsetContext + Logger + NoOpLogger — что тулсеты получают при активации.
- ToolsetRegistry — реестр тулсетов с Mutex-защитой; методы
  activate/deactivate/isActive/activeNames/inactiveNames/activeTools/
  findByName/findOwnerByToolName.
- ToolsetDispatchPolicy — диспетчер с прощающей auto-activation: если тул из
  неактивного тулсета вызван — молча активирует тулсет и выполняет. Если тул
  вообще неизвестен — fallback в BaseToolDispatcher (плоские тулы вне toolsets).
- EnableToolsetTool / DisableToolsetTool — встроенные LiteTool'ы (4-case
  контракт зафиксирован в docs/TOOLSETS-PLAN.md): activate/deactivate с
  равномерным сообщением 'X deactivated' независимо от того, был ли он активен.
- SyncLiteTool — обёртка suspend-handler'а в синхронный LiteTool (через
  runBlocking). LiteTool.invoke синхронен по контракту litert-kmp.

Дизайн:
- :agent-toolsets НЕ зависит от :standalone — может быть переиспользован в
  Android-сборке и любом LiteTool-агенте.
- Модуль KMP (jvm + native), общие интерфейсы в commonMain, JVM-специфика
  только в SyncLiteTool (runBlocking).
- ToolsetContext минимален (logger); storage/skill добавятся в commit 5+.

Тесты: 29 новых покрывают activate/deactivate/idempotency, activeTools,
findOwnerByToolName, auto-activation в диспетчере, fallback в base, оба
контракта enable/disable со всеми 4 кейсами.

Tests: 328/328 green (299 ранее + 29 в :agent-toolsets)
2026-09-15 14:55:51 +03:00
subochev a7d8cbe713 storage-sqlite: выделить SQLDelight + SQLite-импл в отдельный модуль
Перенесён SQLDelight (4 .sq файла, конфигурация databases { AgentikDatabase })
и 5 SQLite-импл классов (SqliteStores, SqliteConversationStore, SqliteMessageStore,
SqliteWorkingMemoryStore, SqliteReflectionStore) из :standalone в новый JVM-only
модуль :storage-sqlite под пакетом pw.binom.agentik.storage.sqlite.

Изменения:
- Новый :storage-sqlite модуль с sqldelight-плагином + sqlite JDBC driver
- Все .sq файлы и Kotlin-классы переехали с переименованием пакета
- ReflectionStore.kt в :standalone (только SQLite-импл) удалён — функционал
  живёт в :storage-sqlite/SqliteReflectionStore.kt
- :standalone/build.gradle.kts: убран sqldelight-плагин и конфигурация,
  добавлена зависимость :storage-sqlite
- Все импорты в :standalone (8 main + 7 test) перенаправлены на новый пакет
- ReflectionStoreTest.kt переехал в :storage-sqlite/jvmTest (тестирует
  internal fun encode/decodeStringArray в :storage-sqlite)

Совместимость:
- SqliteStores доступен по новому пути pw.binom.agentik.storage.sqlite.SqliteStores
- Старые импорты в тестах обновлены (минимум diff — 1 строка на файл)
- В commit 6 ChatAgent переключится на StorageBundle API; SqliteStores
  станет деталью реализации :standalone

Тесты: 299/299 green. Fatjar standalone-all.jar 240 MB.

Преимущества:
- :standalone больше не зависит от SQLDelight плагина (легче поддерживать)
- :storage-sqlite может быть заменён/расширен (например, :storage-android)
- Тесты storage-слоя сгруппированы по модулю реализации
2026-09-15 14:51:58 +03:00
subochev 61f8205f40 storage-inmemory: in-memory импл 4 store'ов
Новый KMP-модуль :storage-inmemory с тред-безопасными (Mutex) in-memory
имплами для всех 4 store'ов из :storage-core:
- InMemoryConversationStore (Map по id, sortedByDescending(updatedAt))
- InMemoryMessageStore (List per conversation, с tokenStats)
- InMemoryWorkingMemoryStore (List с order_idx, atomic compact + summary)
- InMemoryReflectionStore (List по conversationId, FIFO для listRecent)

Фабрика InMemoryStorage.create() возвращает готовый StorageBundle.

Семантика 1:1 с SQLite-имплами — параллельные suspend-вызовы атомарны
через kotlinx.coroutines.sync.Mutex (lost-update race невозможен, как в
SQLite-driver-locked версии).

Тесты: 35 новых, проверяют round-trip всех CRUD-операций, тред-безопасность,
tokenStats агрегацию, compact+summary, events-flow.

Планируемое использование:
- :standalone тесты (вместо SqliteStores.inMemory() с JDBC)
- Android ART-сборка (commit 7+; SQLite требует JDBC драйвера, недоступного
  в Android base classes)
- embedded/cold-start сценарии без SQLite-инициализации

Tests: 299/299 green (264 ранее + 35 новых в :storage-inmemory)
2026-09-15 14:48:29 +03:00
subochev 294837daa0 storage-core: новый KMP-модуль с интерфейсами хранилища
Выносим интерфейсы и data-классы истории диалога (MessageStore / WorkingMemoryStore /
ConversationStore / ReflectionStore + соответствующие sealed-иерархии MessageRecord /
WorkingMemoryEntry / Content / ConversationRecord / Reflection + payload-утилиты) из
:standalone в отдельный KMP-модуль :storage-core (pw.binom.agentik.storage).

Цель — подготовка к Android-портированию и подключению альтернативных реализаций
хранилища без затягивания всей :standalone. Дальше (commit 2/3) — :storage-inmemory
и :storage-sqlite как самостоятельные модули, плюс :storage-android (deferred).

Изменения:
- Новый :storage-core (KMP, commonMain only, jvm + native таргеты) — 12 файлов
- StorageBundle агрегатор (conversationStore + messageStore + workingMemoryStore +
  reflectionStore; SkillStore живёт в :skills и подключается отдельно)
- 11 файлов импортов в :standalone переключены на новый пакет
- SqliteReflectionStore оставлен в :standalone до commit 3 (зависит от
  SQLDelight AgentikDatabase, которую ещё не отвязали от :standalone)
- 4 теста перенесены в :standalone/.../storage/ с обновлённым пакетом
- PayloadTest переехал в :storage-core/commonTest (тестирует чистые типы)

Tests: 264/264 green (179 :standalone + 6 :storage-core + прочие JVM-модули)
2026-09-15 14:39:30 +03:00
subochev e192f58cd0 docs: TOOLSETS-PLAN.md — implementation roadmap for toolsets + storage refactor
Captures the locked architectural decisions, module layout, and 6-commit
sequence. Implementation proceeds autonomously per this plan.

Refs: decisions from session 2026-09-15.
2026-09-15 14:31:59 +03:00
subochev f1cd2e3d42 litert-8: тул-цикл без фантомного trigger-сообщения
litert-api 7 -> 8. addToolResult(callId, name, result: Unit) ->
LiteDelta (несёт текст пост-тул ответа модели + возможные вложенные
tool-calls). caffeine не публикует parent-аггрегатор, поэтому алиасы
в libs.versions.toml указывают на -jvm flavor напрямую.

ChatConversation.runTurn: убран хак currentParts=[Text(' ')] —
вместо него runToolAndPersist(call) -> (callId, resultText) ->
addToolResult() возвращает LiteDelta, цикл идёт по delta.toolCalls.
Никакого 'призрачного' ответа модели в KV-cache после каждого тула.

Live smoke-test (gemma-4-E2B + SigLIP vector backend):
- 'Запомни: работаю на macOS' -> 'Я сохранил информацию о том, что вы
  работаете на macOS' (раньше: 'Чем я могу помочь?')
- 'На чём работаю?' -> 'Вы работаете на macOS'
- цепочка имя->а necdoт -> модель осмысленно продолжает, не сбрасывается
- тесты: 264/264 зелёных
2026-09-15 13:36:40 +03:00
subochev 135c6a419d fix(memory-vector): seed JVector index from SQLite on VectorMemorySystem.open()
После рестарта in-RAM граф JVector создавался пустым (seedEntries не
проходились из metaStore), поэтому search возвращал [], пока не
появлялись новые upsert'ы — память терялась после каждого рестарта.

- VectorMemorySystem.open(): JVectorMemoryIndex(dimension, metaStore.allEntries())
- SqliteMemoryMetaStore.open(): убрал случайное двойное конструирование
- регресс-тест openSeedsIndexFromSqliteAfterRestart (save → close → open → search)
2026-09-15 08:17:42 +03:00
subochev 18ae6e0619 a2a: подключить A2A-транспорт (POST /a2a + agent-card) через A2aBridge
:standalone декларировал зависимость a2a-server, но mount не было (e2e
нашёл 404 на /a2a). A2aBridge (AgentHandler) гоняет A2A-context на
:proto-диалог: contextId -> Conversation (пустой/неизвестный -> новый),
ответ = склеенные AppendText хода (подписка на events() до send, стоп по
End/Interrupted/Error), id диалога в metadata.agentikConversationId.
2026-09-15 07:31:54 +03:00
subochev 9b37edd92e fix(skills): SkillParser.serialize — закрывающий fence прилипал к последней YAML-строке
kaml encodeToString не ставит завершающий перевод строки, из-за чего
сериализованный SKILL.md выглядел так:
  ---
  name: x
  description: "y"---
и SkillParser.parse находил MissingClosingFence — каждый скил,
сохранённый через skill_save / SkillMiner, становился нечитаемым после
рестарта агента (каталог терял скил).

Поставлен явный '\n' перед закрывающим fence. Добавлены юнит-тесты
round-trip serialize->parse (обычный, пустой body, спецсимволы YAML).
2026-09-15 07:01:23 +03:00
subochev df386ef875 skills: SkillMiner — фоновое авто-создание скилов + debug-эндпоинты
SkillMiner (сетка безопасности skill self-improvement): каждые
AGENTIK_SKILL_MINING_INTERVAL user-ходов (default 15) LLM смотрит
последние AGENTIK_SKILL_MINING_MAX_TURNS ходы (default 30) + каталог
существующих скилов и возвращает structured JSON {"skills":[...]}.
Найденное upsert-ится в SkillStore — модель "забыла" вызвать
skill_save в ходе разговора, минер добирает её постфактум.

- SkillMiner.kt: короткий LiteConversation (one-shot), blocking-инференс
  на Dispatchers.IO, defensive парсинг (кривой ответ -> пустой список).
- SkillMiningPrompts/SkillMiningParser: тот же подход, что
  ReflectionParser (structured-output вместо tool-calling).
- ChatConversation.scheduleSkillMining() — хук после каждого хода
  (рядом со scheduleReflection); ChatAgent/Main — прокидывание.
- DebugRoutes.kt: AGENTIK_DEBUG_ENDPOINTS=1 включает POST
  /debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact и
  GET /debug/tokens для ручного триггерирования фоновых фич.
- ChatConversation.forceCompactNow(): принудительный compaction
  без проверки порога (для /debug/compact).
- Тесты: SkillMinerTest (5) + SkillMiningParserTest (9); FakeLiteLlm
  теперь записывает send()/sendContents() в lastContents.

179 jvm-тестов :standalone зелёные, README обновлён.
2026-09-15 06:36:23 +03:00
subochev f4ce82957b standalone: token accounting per assistant turn (input/output → SQLite)
LiteLlm API не отдаёт split prompt/completion наружу через send()
(внутренний OpenAI Usage сидит в pw.binom.litert.openai и недоступен),
поэтому измеряем через LiteConversation.tokenCount():

  input  = tokenCount() до первого send в turn'е
          (= system + вся история + tools + только что добавленное user-сообщение)
  output = tokenCount() после завершения turn'а - input
          (= assistant text + tool calls + tool results за tool loop)

Пишем в assistant-запись как TurnTokens(input, output) в payload_json.
Никаких schema-миграций: payload-формат уже обёрнут в MessageBodyPayload,
просто добавлено опциональное поле tokens.

MessageStore.tokenStats(conversationId) → TokenStats(turns, inputTokens, outputTokens).
На старте агент печатает сводку по всем диалогам:
  tokens: 17 convs, 134 turns, in=523844, out=58290, total=582134

Бэкенды без tokenCount() (off-line LiteRT-LM модели) → tokens=null,
старые assistant-записи без метрики → пропускаются в tokenStats без ошибок.

Tests: TokenStatsTest (5 green) + PersistenceTest (unchanged) → 165 total.
Backward compat: legacy plain-array payload всё ещё читается, tokens=null.
2026-09-15 05:45:40 +03:00
subochev 9afa877e39 tools: подключить skill_save/skill_delete к ChatAgent (Hermes Phase 3 ready)
Тулзы были написаны ранее (SkillSaveTool/SkillDeleteTool, SkillToolsFactory),
но не были подключены к ChatAgent. Этот коммит закрывает пробел:

* ChatAgent: новый параметр skillStore: SkillStore? = null. Когда задан —
  в allTools добавляются SkillToolsFactory.create(skillStore) → агенту
  доступны skill_save и skill_delete (помимо read_skill который всегда
  есть при непустом каталоге).
* Main.kt: если config.skillsDir задан — создаём DiskSkillStore(File(dir))
  и скармливаем агенту. Каталог используется и для чтения (SkillCatalog),
  и для записи (DiskSkillStore.upsert/remove) — одни и те же файлы,
  никаких рассинхронов между read_skill и skill_save.
* SkillLoader.loadDirectory больше не нужен в Main — DiskSkillStore сам
  подгружает каталог в init. Удалён старый импорт.
* Тесты SkillToolsTest (5): SkillSaveTool persists file and surfaces in
  catalog; rejects blank name; SkillDeleteTool archives (rename to
  .archived); errors on missing skill; colon-named skills map to nested
  dirs (backend:spring:db-base → backend/spring/db-base/SKILL.md).
* README: раздел "Навыки" расширен описанием трёх тулов (read_skill /
  skill_save / skill_delete).

Smoke: standalone запускается с пустым AGENTIK_SKILLS_DIR, видит
"skills: 0 loaded from /tmp/skills-smoke" (DiskSkillStore создаёт каталог
при отсутствии). Агент при наличии skillStore имеет в своём распоряжении
все три тула для self-improvement'а.

Теперь Phase 3 (skill self-improvement) реально работает end-to-end:
агент может дёрнуть skill_save когда понимает что задача повторяется,
потом в следующих диалогах использовать новый скил через read_skill.

Tests: 160 standalone JVM (+5), 241 всего JVM, 73 native, all green.
2026-09-15 05:20:41 +03:00
subochev 23da1f6498 reflection: Hermes-style self-reflection (последняя открытая Hermes-фича)
Self-reflection: каждые N пользовательских ходов агент запускает one-shot
LLM-размышление о качестве своих ответов, сохраняет score+weakSpots в SQLite,
подмешивает top-K последних рефлексий в system prompt как "слабые места".

* sqldelight: новая таблица reflection (id, conversation_id?, created_at,
  turns_analyzed, score, summary, weak_spots_json) + индексы по created_at и
  conversation_id.
* persistence: Reflection data class + ReflectionStore interface +
  SqliteReflectionStore (insert/get/listRecent/listForConversation/
  deleteOlderThan/count + events Flow). weakSpots хранятся как JSON-массив,
  парсятся ручным сканером (без kotlinx-serialization в этом модуле).
* agent: LlmReflector (one-shot LiteLlm через createConversation +
  send, structured-output JSON). ReflectionParser (hand-rolled,
  толерантный к ```json fences и лидирующему/завершающему тексту;
  score принимает int или строку; weakSpots — массив).
* agent: ReflectionPrompts (Russian system+user prompts, аналогично
  LlmMemoryReviewer/ReviewPrompts).
* agent: ChatAgent.buildSystemPrompt расширен параметром reflections —
  добавляется секция `## Self-reflection: твои слабые места за последнее время`
  после memory и перед soul-prepend.
* agent: ChatConversation.scheduleReflection — каждые reflectionInterval
  пользовательских ходов (счётчик через workingMemory.list) запускает
  reflector на Dispatchers.IO, результат сохраняет в reflectionStore
  с conversationId. Не блокирует turn.
* AgentikConfig: новые поля reflectionInterval (env AGENTIK_REFLECTION_INTERVAL,
  default 10, clamped 0..1000) и reflectionTopK (env AGENTIK_REFLECTION_TOP_K,
  default 3, clamped 0..20).
* Main.kt: если reflectionInterval > 0 — создаём LlmReflector(llm); загружаем
  top-K из SQLite в system prompt.
* Ids.reflection() — генератор id "refl-<uuid>".
* Tests: ReflectionParserTest (7: clean JSON, fences, лидирующий текст,
  score-as-string, отсутствие score, невалидный JSON, escape-последовательности),
  ReflectionStoreTest (7: round-trip, listRecent с лимитом, фильтр по
  conversation_id, deleteOlderThan, count, encode/decode строк), и
  ChatAgentReflectionTest (3: секция скрыта при пустых, присутствует с
  score+spots, порядок soul→memory→reflection).
* README: новые env-переменные, раздел "Self-reflection", startup output.

Smoke test подтверждает: reflection секция появляется в system prompt когда
в SQLite есть записи (listRecent возвращает непустой список). При
reflectionInterval=0 reflector не создаётся, scheduleReflection — no-op.

Tests: 305 total green (+17: 7+7+3). Fatjar собирается, logback-вывод
работает (logging: см. предыдущий коммит f5a551b).
2026-09-15 05:14:33 +03:00
subochev f5a551b2ae logging: kotlin-logging 3.0.5 + logback-classic + AGENTIK_LOG_LEVEL env
Заменил все System.err.println / println на структурное логирование
(kotlin-logging, пакет mu) — теперь логи идут с timestamp/level/thread/logger.

* gradle/libs.versions.toml: kotlin-logging = "3.0.5" (в прокси доступна
  только эта версия; новые 7.x пока не подтянуты), logback-classic = "1.5.18".
* standalone/build.gradle.kts: implementation(libs.kotlin.logging) +
  implementation(libs.logback.classic) в jvmMain.
* standalone/src/jvmMain/resources/logback.xml: консольный appender,
  pattern с timestamp/level/thread/logger, level управляется через
  ${AGENTIK_LOG_LEVEL:-INFO} (env override на старте JVM), уровни
  io.netty/ai.onnxruntime уведены в WARN чтобы не забивать канал.
* Заменены все System.err.println в: ChatConversation (14 callsites),
  Curator (2), McpRegistry (5), McpConfig (2), Main (2). Startup banner
  в Main оставлен на println — это user-facing output, не log.
* Tests: 288 зелёных (только замена log-вызовов, без изменения семантики).

Smoke: `04:53:49.313 INFO  [DefaultDispatcher-worker-4] p.b.a.s.agent.memory.Curator - started (interval=1d, maxAge=90d, maxUseCount=0)`
подтверждает структурный лог вместо println. AGENTIK_LOG_LEVEL=DEBUG работает.
2026-09-15 04:54:16 +03:00
subochev 427ce8a572 memory: SigLIP2 on-device embedding (text-embedding-kmp v3)
Добавляет второй бэкенд эмбеддингов для vector-памяти: on-device SigLIP2
через ONNX Runtime. Не требует сети (HTTP), не светят тексты заметок наружу.

* gradle/libs.versions.toml: text-embedding-kmp = "3.0.0-SNAPSHOT", модули
  api-jvm / siglip-jvm (group переехал с pw.binom.voice.embeddingtext на
  pw.binom.ai.embeddingtext).
* settings.gradle.kts: mavenLocal() добавлен в dependencyResolutionManagement
  (text-embedding-kmp публикуется локально как snapshot).
* memory-vector/SiglipEmbeddingProvider (jvmMain) — адаптер
  pw.binom.voice.embeddingtext.TextEmbeddingExtractor → EmbeddingProvider:
  оборачивает blocking embed() в withContext(Dispatchers.IO) + Mutex (ONNX
  сессия не reentrant), размерность пробируется через probe embed("probe")
  (768 для SigLIP2-base).
* memory-vector/build.gradle.kts: api(libs.text.embedding.api) +
  implementation(libs.text.embedding.siglip); jvmTest получает
  SiglipEmbeddingProviderTest — smoke test (skip если модель не найдена).
* standalone/AgentikConfig: новый enum EmbeddingBackend { HTTP, SIGLIP },
  поля embeddingBackend / embeddingModelPath / embeddingTokenizerPath, env:
  AGENTIK_EMBEDDING_BACKEND, AGENTIK_EMBEDDING_MODEL_PATH,
  AGENTIK_EMBEDDING_TOKENIZER_PATH.
* standalone/Main.kt: switch на embeddingBackend при memory-backend=vector;
  для SIGLIP требуются оба пути, иначе ошибка с понятным сообщением.
* standalone/README.md: обновлены env-vars, добавлены две bash-секции
  (HTTP и SIGLIP) + инструкция скачивания модели с static.binom.pw.
* scripts/install-text-embedding-stub.sh: workaround для upstream бага
  (siglip-jvm/*.module ссылается на api без -jvm variant). Создаёт
  stub-артефакт api:3.0.0-SNAPSHOT в mavenLocal с тем же содержимым.
  Удалить когда upstream починит module-metadata.

Smoke test: AGENTIK_MEMORY_BACKEND=vector AGENTIK_EMBEDDING_BACKEND=siglip
+ несуществующий путь → FileNotFoundException с понятным трейсом (значит
ONNX Runtime инициализирован, путь через factory пробрасывается корректно).

Tests: 288 total green. Fatjar 250MB (вырос из-за onnxruntime ~80MB).

Dropped: старый stub-jar pw.binom.voice.embeddingtext:api:2.0.0-SNAPSHOT.
2026-09-15 04:43:45 +03:00
subochev 9e12b22e85 memory: Curator (Phase 4) — фоновая архивация старых неиспользуемых заметок
* :memory-api — MemoryStore.archiveStale(maxAge, maxUseCount, now): default
  имплементация через list + delete (бэкенды могут переопределить).
* :standalone — Curator: фоновая корутина на Dispatchers.IO, раз в сутки
  дёргает archiveStale(90d, 0). start()/stop(), runPass() — однократный
  прогон для тестов.
* :standalone/Main — Curator стартует автоматически если memory включён,
  stop() в shutdown hook.
* :standalone — публичный TestInMemoryMemoryStore вынесен из CompactionTest,
  переиспользуется в CuratorTest.
* README — раздел "Куратор памяти" с описанием семантики для md и vector
  бэкендов.

Tests: 288 total (+4 CuratorTest). Fatjar smoke-tested, curator стартует
на app boot, выводит `[Curator] started` в лог.

Defaults: interval=1d, maxAge=90d, maxUseCount=0. Override через
новые config-флаги отложен.
2026-09-15 04:14:03 +03:00
subochev 227d14b7e4 memory-vector: LlmMemoryReviewer + SkillStore/SkillSaveTool/SkillDeleteTool
Phase 3 (Hermes-style self-improvement) and Phase 5.2 (LLM-driven review):

* :skills — SkillStore interface + DiskSkillStore (upsert/remove, file<->catalog sync)
* :skills — SkillParser.serialize for write-back path
* :standalone — SkillSaveTool/SkillDeleteTool + SkillToolsFactory
* :standalone — LlmMemoryReviewer: one-shot LiteLlm review via structured-output
  JSON prompt ({toSave:[...], toDelete:[...]}); reuses MemorySystem store
* :standalone — ReviewDecisionParser (lenient, handles json fences, missing
  arrays, malformed numbers)
* :standalone — ReviewPrompts (Russian system+user prompts, fact categories)
* :standalone/Main — wires LlmMemoryReviewer instead of KeywordMdReviewer when
  LLM is available, falls back to keyword for off/md-only mode
* :client — send(content, context) overload + SendPayload wrapper
* :proto — ExperimentalNativeApi opt-in for MessageContextTest (native targets)

Bug fixes:
* SkillTools.kt: error() shadowed kotlin.error(); renamed to Nothing
* ChatConversation: .map { when(...); error() } → .mapNotNull { when ... else -> null }
  (Kotlin type inference of LUB LiteMessage | Nothing failed across when-expr)

Tests: 284 total green (memory-vector: 17, standalone: 149).

Dropped: IRC-QUESTIONS.md (irc-server design rejected — user decision 2026-09-14).
2026-09-15 04:08:46 +03:00
subochev 65365da89c Phase 5: :memory-vector (JVector + SQLite + LLM-эмбеддинги), memory-abstraction, compaction, MessageContext
- :memory-api — общий контракт MemoryStore/Prefetcher/Reviewer/Tools/MemorySystem
- :memory-md (KMP, kotlinx-io) — Hermes-style §-файлы, keyword overlap
- :memory-vector (JVM-only) — JVector ANN + SQLite + HttpEmbeddingClient
- :standalone — AGENTIK_MEMORY_BACKEND={md,vector,off}, выбор в Main.kt
- :standalone — compaction рабочего контекста (LiteLlmContextCompactor + reviewPreCompaction)
- :proto — MessageContext (origin: user/system/event) на send и в Message
- :server — backward-compat dual-format для POST /messages
- README — env-vars, vector-бэкенд docs
2026-09-15 03:44:15 +03:00
subochev 0faad45f3d standalone: persist turn errors so polling clients see them
Event.Error уже эмитился в live-SSE, но если клиент подключился
после провала хода (или опрашивает историю через getMessages
вместо SSE), он видел только user-сообщение без следа, что ход
провалился. Это и нужно было поправить.

* :proto
  - Message.Error(id, message, code?, date) — терминальная
    персистентная проекция Event.Error. Audit-only; в live-стриме
    по-прежнему приходит Event.Error.

* :standalone
  - MessageRecord.Error с тем же контрактом.
  - SqliteMessageStore: kind "error", JSON-payload {message, code};
    encoding-ошибки round-trip покрыты тестом (с code и без).
  - ChatConversation.failTurn(message, code?): пишет MessageRecord.Error
    в audit (кроме temp-диалогов), затем эмитит Event.Error + Event.End.
    Все три exit-точки из runTurn (пустой parts, init-catch,
    stream-catch) теперь через failTurn.
  - ChatConversation: при ошибке стрима живой LiteConversation
    сбрасывается — следующий send пересоберёт его из working_memory.
    Раньше оставляли битую инстанцию вопреки KDoc класса.
  - toProto: маппит MessageRecord.Error → Message.Error.

* docs
  - STANDALONE.md: добавлен Message.Error в таблицу dual-log + абзац
    про персист ошибок (audit/working_memory, сброс liteConv).
  - ARCHITECTURE.md: Message.Error в сигнатуре Message, failTurn
    в ChatConversation, Message.Error в audit log.
  - Поправлен пример describe() в доке тулов (реальный формат —
    OpenAI-style function wrapper, а не голый JSON Schema).

Тесты:
  - ChatAgentTest: LLM failure → Event.Error + Event.End + Error
    record в audit + backfill через getMessages.
  - PersistenceTest: MessageRecord.Error round-trip (с code и без).
  - :standalone jvmTest 70 (было 69).

E2E: неверный model id (HTTP 400) → polling GET /messages теперь
возвращает user_message + error (без assistant_message).
2026-09-14 00:26:13 +03:00
subochev adcb8f54d6 skills: каталог + ленивая загрузка read_skill
Пользовательские инструкции («навыки») живут в указанной папке
(AGENTIK_SKILLS_DIR), рекурсивно читаются при старте и попадают в
системный промпт в сжатом виде: только имя + краткое описание.
Полный текст модель подгружает по требованию, вызывая встроенный
инструмент read_skill(name).

*:skills
  - SkillCatalog + SkillPrompt (commonMain): рендер секции системного
    промпта; тело навыка в промпт не течёт.
  - SkillParser.parseAuto(): теперь читает и opencode-стиль SKILL.md
    (YAML frontmatter + markdown тело), и голый *.yaml/*.yml
    (поля name, description, опц. body). parseOrThrow для strict-путей.
  - SkillParseError.render(): человекочитаемое описание ошибки для
    логов и диагностики.
  - SkillLoader (jvmMain): рекурсивный обход папки, детерминированный
    порядок (по пути), ошибки отдельных файлов не валят загрузку;
    дубликаты имён → ошибка, выигрывает первый по пути.

* :standalone
  - AgentikConfig.skillsDir + env AGENTIK_SKILLS_DIR.
  - ChatAgent: параметр skills (SkillCatalog); системный промпт
    автоматически дополняется секцией «## Навыки» и в working memory
    сидится вместе с базовым промптом.
  - При непустом каталоге в tools автоматически добавляется
    SkillReadTool (имя read_skill) — модель может загрузить полный
    текст навыка, как обычный LiteTool.
  - Main.kt: загружает навыки и шумно логирует ошибки загрузки в stderr.

* docs
  - STANDALONE.md: секция «Навыки (skills)», env-переменная в таблице.
  - Формат SKILL.md (opencode frontmatter) + голый *.yaml/*.yml.

Тесты: :skills jvmTest 34, :standalone jvmTest 69 (новые — состав
системного промпта, регистрация read_skill, навыки не утекают в
промпт телом).
2026-09-14 00:25:22 +03:00
subochev 3fde5c28e3 config: единая AgentikConfig DTO + сериализация всей конфиг-цепочки
- AgentikConfig(port, dbPath, llm, mcp) — единая точка входа для всего,
    что настраивается снаружи; Main.kt больше не читает System.getenv,
    только config.*. Наполняется пока из env (AgentikConfig.fromEnv).
  - @Serializable на AgentikConfig/LlmConfig/GoogleConfig/McpConfig/
    McpServerSpec(Stdio,Http). Внешний litert OpenAiConfig (не сериализуемый)
    заменён нашим pw.binom.agentik.standalone.llm.OpenAiConfig с маппером
    toLitertConfig() — чтобы позже читать конфиг из yaml.
  - AgentikConfigTest: дефолты, env-override, делегирование в LlmConfig/McpConfig,
    JSON round-trip с полиморфным McpServerSpec.

Проверка: :standalone:jvmTest 54/54; смок через DTO — /health=ok.
2026-09-13 21:49:12 +03:00
subochev f4caab940e skills: парсер YAML-скилов (opencode-формат)
Новый KMP-модуль :skills (9 целей, зеркалит :proto). Парсит скилы
в формате opencode: YAML-фронтматтер с name+description, затем markdown
body.

  - SkillFile(name, description, body) — @Serializable результат.
  - SkillParseError — sealed-иерархия ошибок: Empty, MissingOpeningFence,
    MissingClosingFence, InvalidYaml, SchemaMismatch, BlankName.
  - SkillParser.parse(raw): SkillParseResult + parseOrThrow(raw): SkillFile.
    Обрабатывает BOM, CRLF, имена с двоеточиями (backend:spring:db-base),
    multiline-YAML, пустое тело; неизвестные ключи фронтматтера игнорирует.
  - SkillParserTest — 16 тестов, 0 failures.

YAML-библиотека — com.charleskorn.kaml 0.104.0 (не JetBrains,
интеграция с kotlinx @Serializable). Грабли kaml 0.104: параметр
ignoreUnknownKeys переименован в strictMode (инвертирован);
missing-required-field прилетает как YamlException, не
SerializationException — различаем по сообщению.

Проверка: :skills:jvmTest 16/16, :skills:assemble (9 целей) — зелёные.
2026-09-13 21:28:47 +03:00
subochev cad4d5fc7c AGUI: удалён полностью
Убрана зависимость pw.binom.agui:server из проекта — AGUI больше
не нужен (см. Memory #3675; agentik переходит на свой протокол :proto
и HTTP-фасад :server).

Чистка:
  - standalone/build.gradle.kts:    implementation(libs.agui.server) → удалено
  - gradle/libs.versions.toml:      [versions] agui + agui-api/agui-client/
                                    agui-server entries → удалены
  - settings.gradle.kts:            убран 'AG-UI' из комментария к Nexus-репо
  - proto/build.gradle.kts:         комментарий 'Зеркалит набор AG-UI api' →
                                    'Полный набор KMP-целей'
  - proto/src/.../Agent.kt:         KDoc '(замена AG-UI)' → удалено
  - settings.gradle.kts:            то же
  - docs/ARCHITECTURE.md:           переписан (описывал старую AGUI-centric
                                    архитектуру с AbstractAgent/SessionStore/
                                    AgentEngine — ничего этого в коде уже
                                    нет; теперь отражает текущее состояние:
                                    :proto + :server + :standalone + LiteLlm +
                                    MCP + SqliteStores)
  - NATIVE-COMPATIBILITY.md:        пункт 10 'agui-server KMP-готовность' →
                                    отменён (см. -); убран из 'Жёсткие блокеры'

В коде не осталось ни одного обращения к AGUI. После чистки в репо
больше нет ни одной зависимости от pw.binom.agui.*.

Проверка: ./gradlew :server:assemble (9/9 KMP-целей) +
:standalone:jvmTest 44/44 (ChatAgent 15, LlmConfig 6, McpConfig 8,
McpRegistry 4, Persistence 11) — зелёные.
2026-09-13 19:10:20 +03:00
subochev d5e3f2dcef proto: drop dead kotlinx-datetime dep, keep only kotlin.time.Instant
Punkt 7 of NATIVE-COMPATIBILITY.md. Proto sources were already on
kotlin.time.Instant (migrated earlier); the kotlinx-datetime api dep
in :proto/build.gradle.kts and the implementation dep in
:standalone/build.gradle.kts were dead weight.

Dropped:
  - api(libs.kotlinx.datetime) from proto/build.gradle.kts
  - implementation(libs.kotlinx.datetime) from standalone/build.gradle.kts
  - [versions] kotlinx-datetime and [libraries] kotlinx-datetime from
    gradle/libs.versions.toml

Verification: ./gradlew :server:assemble (all 9 native targets) +
./gradlew :standalone:jvmTest (44/44 green, 0 failures) — both pass.
The kotlinx-datetime typealias was deprecated in 0.8.0 (memory #3708);
now nothing in agentik pulls the library.
2026-09-13 19:04:32 +03:00
subochev 5b4c8ceae8 server: convert :server from kotlin-jvm to KMP with all 9 targets
Punkt 6 of NATIVE-COMPATIBILITY.md. Mirrors :proto's target set:
jvm + macosX64 + macosArm64 + iosX64 + iosArm64 + iosSimulatorArm64
+ linuxX64 + linuxArm64 + mingwX64.

All 9 targets compile via './gradlew :server:assemble'.

No blockers: the only deps (ktor-server-core, ktor-server-content-
negotiation, ktor-serialization-kotlinx-json, kotlinx-coroutines-core,
kotlinx-serialization-json) ship native variants for every target.
kotlin.time.Instant was already in use (not the deprecated
kotlinx.datetime.Instant), so no proto-side work needed.

Only API change vs the JVM version: respondTextWriter (JVM-only) ->
respondBytesWriter + writeStringUtf8 (KMP, ByteWriteChannel API).
SSE behaviour is identical ('data: <json>\n\n' chunks flushed as
they arrive).

Verification: :standalone:jvmTest 44/44 green after the migration
(ChatAgent 15, LlmConfig 6, McpConfig 8, McpRegistry 4, Persistence 11).
No call-site changes needed; :standalone picks up the new :server
jvm artifact automatically.
2026-09-13 18:28:12 +03:00
subochev adab112b5b docs: NATIVE-COMPATIBILITY.md checklist for linuxX64 bring-up 2026-09-13 18:20:10 +03:00
subochev e816d8d9d1 prepare linuxX64: switch to kotlin.uuid.Uuid and ktor-server-cio
1. UUID: java.util.UUID.randomUUID() → commonMain helper Ids.new(prefix)
   wrapping kotlin.uuid.Uuid.random().  kotlin.uuid is stdlib (KMP: jvm +
   all native targets), so the call sites (ChatAgent, ChatConversation,
   SqliteWorkingMemoryStore) are now ready for native builds.  Format kept:
   '<prefix>-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.

2. HTTP engine: ktor-server-netty → ktor-server-cio.  Netty is JVM-only;
   CIO is KMP (jvm, linuxX64, ios, etc.) and feature-complete for our
   needs (POST + SSE).  standalone/build.gradle.kts drops netty and adds
   cio; Main.kt imports io.ktor.server.cio.CIO and calls
   embeddedServer(CIO, port = port).

Netty is left in libs.versions.toml (catalog entry kept) in case anyone
needs to fall back; only :standalone stopped depending on it.

E2E verified end-to-end against litellm local/codding: standalone-1.0.0
tarball starts on AGENTIK_PORT, GET /health -> 'ok', POST
/agentik/conversations -> 201 with new UUID-format id, POST .../messages
returns real LLM answer ('Ассистент').  44/44 unit tests green.
2026-09-13 18:16:30 +03:00
subochev 1e4c4d679f mcp+tools: preserve JSON primitive types across the wire
McpLiteToolAdapter.parseArgsJson() used JsonPrimitive.content for every value
(always String), so MCP servers saw e.g. max_length:"500" and rejected
calls with '500' is not of type 'integer'. Replaced with kotlinx.serialization
booleanOrNull/intOrNull/longOrNull/doubleOrNull ladder that mirrors the
litert-openai parser.

ChatConversation.encodeArgsJson was also still calling encodeToolArgs
(stub from earlier pass). Inlined a small Any?.toJsonElement() helper
that preserves Boolean/Number/String/Map/List types when re-serializing
the LiteToolCall.arguments map into the argsJson string fed to
tool.invoke().

E2E re-verified: litellm local/codding + mcp-server-fetch now invokes
fetch__fetch({max_length:2000,url:...}) once, MCP gets a real
'Failed to fetch robots.txt' from the actual network sandbox instead
of rejecting the schema, LLM reports the real cause to the user.
2026-09-13 17:50:41 +03:00
subochev 4c66947c25 standalone: add MCP client + tool-call loop
- Add mcp/McpConfig + McpRegistry wrapping io.modelcontextprotocol:kotlin-sdk-client 0.15.0
  - Supports stdio (uvx/npx/python) and streamable HTTP transports
  - Claude Desktop-compatible JSON config (AGENTIK_MCP_CONFIG)
  - server__tool name prefix to avoid collisions between servers
- Add agent/NamedTool (name + LiteTool pair) and tools: List<NamedTool> on ChatAgent/ChatConversation
- Implement tool-call loop in ChatConversation.runTurn:
  - delta.toolCalls -> emit ToolCall event -> persist audit -> execute tool
    -> emit ToolResult -> persist -> liteConv.addToolResult(callId, name, result)
  - separate tc-/tr- prefixes keep SQL PRIMARY KEY unique while toolCallId FK is preserved
- 8 McpConfig + 4 McpRegistry unit tests; +1 ChatAgentTest tool-loop test (44/44 total)
- e2e verified: real MCP fetch server (mcp-server-fetch) + litellm local/codding
  -> LLM calls fetch__fetch, MCP exec, result fed back, conversation continues

docs/STANDALONE.md: drop 'no tools / no MCP' from §8; replace 'Подключить тул (v2)' stub
with full in-agent + MCP recipe and tool-loop algorithm in §7
2026-09-13 15:59:58 +03:00
subochev afdfb37e35 Upgrade litert-api/litert-google/litert-openai to v7 (litertlm-jvm 0.17.0), drop workarounds 2026-09-13 14:46:09 +03:00
subochev 9e5d61707d standalone v1: dual-backend (openai + litert-google) with SQLDelight dual-log persistence
Replace EchoProtoAgent / EchoAgent / EchoA2aHandler placeholders with a real
stateful agent on top of SQLite (SQLDelight 2.3.2) and litert-api v6.

persistence (commonMain):
- ConversationStore / MessageStore / WorkingMemoryStore — three narrow
  interfaces, all operations suspend, AutoCloseable.
- MessageRecord sealed: UserMessage / AssistantMessage (Body subtype),
  ToolCall / ToolResult (audit-only), Summary / System (working-memory-only
  synthetic). Snake-case @SerialName discriminators.
- WorkingMemoryEntry sealed: System / User(sourceMessageId) /
  Assistant(sourceMessageId); sourceMessageId is null for System.
- Two-table dual-log model: append-only message audit + mutable
  working_memory with monotonic order_idx.

SQLite (jvmMain):
- SQLDelight schema + SqliteConversationStore / SqliteMessageStore /
  SqliteWorkingMemoryStore under src/jvmMain/sqldelight/.
- SqliteStores.open(path) / inMemory(); Schema.create gated on
  sqlite_master probe for idempotency.
- All payload_json is the MessageRecord encoded as JSON; subtype-specific
  fields avoid migrations.

agent (jvmMain):
- ChatAgent — stateful proto.Agent with live in-memory cache, lock-protected,
  AgentEvent bus (Created/Deleted).
- ChatConversation — long-lived LiteConversation handle; created lazily on
  first send from working_memory (system + initial messages), reused across
  all subsequent turns (REQUIRED for litert-google KV-cache).
- Per turn: append User to audit + WM → sendStreamContents (wrapped in
  transformWhile for litert-google-jvm 0.16.1 isDone workaround) → emit
  AppendText deltas → append Assistant to audit + WM + touch conversation.
- isClosed flag so getConversation reconstructs after close.

llm (jvmMain):
- LlmConfig data class with LlmBackend enum (OPENAI / GOOGLE); fromEnv
  parses AGENTIK_LLM_BACKEND and dispatches to backend-specific config.
- OpenAI: litert-openai, OpenAI-compatible endpoint, validated
  baseUrl/apiKey/model.
- Google: litert-google (reflection-resolved pw.binom.litert.google
  factory) on top of litertlm-jvm 0.16.1 native engine;
  visionBackend/audioBackend = null (LiteRT-LM 0.16.1 binds encoder
  graph even with null backend, but a model lacking encoder crashes;
  null is the correct "don't bind" signal).
- foldSystemIntoFirstUser (default true for GOOGLE) folds system prompt
  into the first user message to avoid chat template alternation issues.

build:
- Add sqldelight plugin + runtime + sqlite-driver + coroutines-extensions
  to gradle/libs.versions.toml.
- litert-openai: implementation; litert-google: runtimeOnly (resolved via
  reflection at runtime).
- KMP jvm executable via @OptIn(ExperimentalKotlinGradlePluginApi) +
  jvm { binaries { executable { mainClass.set("...MainKt") } } }.

tests (jvmTest): 30 passing
- PersistenceTest (11): conversation upsert/list/cascade-delete/rename/
  touch; message audit append/list; working-memory order preservation;
  image-content payload roundtrip.
- ChatAgentTest (14): system-prompt seeding; persistent vs temp
  persistence across SqliteStores reopen; multi-turn audit + WM growth;
  interrupt of in-flight slow send; agentEvents Created/Deleted flow;
  closed-conv reconstruct via getConversation.
- LlmConfigTest (6): env happy path, defaults, missing fields throw.

smoke tested e2e:
- openai backend against real llm.binom.pw/v1 (myopenai/local/codding)
  — multi-turn dialogue persisted, kill -9 + restart survives.
- google backend against gemma-4-E2B-it.litertlm — multi-turn
  ("Hello there!" → "2 + 2 = 4"), KV-cache survives across turns,
  SSE start→append_text*→end cleanly closes.

docs/STANDALONE.md updated for v1 architecture, dual-backend env table,
long-lived LiteConversation invariant, and litert-google-jvm 0.16.1
isDone-stream workaround.
2026-09-13 12:39:36 +03:00
subochev a3581abf84 Bring up :proto protocol + :server (Ktor) + :client (HTTP) modules; wire :server into standalone with EchoProtoAgent
Major additions:

* :proto (KMP submodule) — in-house stateful protocol replacing AG-UI.
  Agent owns conversation transcript; Conversation.events(after) is a live,
  replay-free stream; backfill via Conversation.getMessages(after, offset, limit).
  Each Event carries an Instant date for client-side resume tracking.
  Sealed hierarchies (Content/Message/Event/AgentEvent) annotated @Serializable
  with snake_case @SerialName JSON discriminators so the wire format is
  decoupled from Kotlin class names.

* :server (JVM, Ktor 3.1.3) — REST+SSE facade for Agent.
  Public entry: Route.agentikAgent(agent, path = "/agentik").
  Endpoints: create/list/get/patch/delete conversations, POST messages (202),
  POST interrupt, GET messages, GET conversation events (SSE),
  GET agent events (SSE), GET /health. Custom Instant serializer for
  kotlin.time.Instant registered contextually on agentikJson (ISO-8601,
  ignoreUnknownKeys=true, explicitNulls=false).

* :client (JVM, Ktor HTTP Client + CIO) — mirror of :server returning
  a pw.binom.agentik.proto.Agent backed by HTTP calls. Custom SSE parser
  since ktor-client-sse is not on the 3.1.3 client classpath.

* standalone — EchoProtoAgent (in-memory Agent for :proto), EchoAgent
  (existing AG-UI echo), both mounted on the same Netty embedded server
  on port 8080 (/agui and /agentik); A2A stays on its own CIO engine on
  8081. EchoProtoAgent smoke-tested end-to-end against :server: all 11
  endpoints, including live SSE delivery of StartResponse/AppendText/End
  event triplets and Agent-level Created/Deleted events.

Design notes pinned in:
* agentik/IRC-QUESTIONS.md — closed 13-item checklist for the upcoming
  :irc-server transport (channel = conversation, CTCP for structural
  events, draft/chathistory for backfill, ImageStore side-channel, etc).
* docs/ARCHITECTURE.md — overall layout snapshot.
2026-09-12 01:10:27 +03:00
224 changed files with 24106 additions and 0 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
+75
View File
@@ -0,0 +1,75 @@
# Триггерится при публикации релиза в 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
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'adopt'
- name: Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
.gradle
key: ${{ runner.os }}-gradle-agentik-${{ hashFiles('**/*.gradle.kts', '**/gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-agentik-
- name: Publish libraries (all KMP targets, all modules)
shell: bash
env:
BINOM_REPO_USER: ${{ secrets.BINOM_REPO_USER }}
BINOM_REPO_PASSWORD: ${{ secrets.BINOM_REPO_PASSWORD }}
BINOM_REPO_URL: ${{ vars.BINOM_REPO_URL }}
run: |
# Gitea Actions (Forgejo-based) экспонирует env-переменные под
# GITHUB_-префиксом: GITHUB_REF_NAME = "v0.1.0" для tag-trigger'а.
# Внутри bash подставляем через $GITHUB_REF_NAME (а не
# ${GITEA_REF_NAME} — Forgejo этого не подставляет).
#
# Версия = имя тега (с trim'ом опционального префикса 'v'), чтобы
# тег "1" публиковался как pw.binom.agentik:<module>:1. CICD не
# хардкодит версию — берёт её из тега каждый раз.
TAG="$GITHUB_REF_NAME"
VERSION="${TAG#v}"
echo "Publishing version: ${VERSION}"
./gradlew \
"-Pversion=${VERSION}" \
"-Pbinom.repo.url=${BINOM_REPO_URL}" \
"-Pbinom.repo.user=${BINOM_REPO_USER}" \
"-Pbinom.repo.password=${BINOM_REPO_PASSWORD}" \
publish \
-Dorg.gradle.jvmargs=-Xmx4096M \
--no-daemon --no-watch-fs --stacktrace
+27
View File
@@ -0,0 +1,27 @@
# Gradle
.gradle/
build/
**/build/
# Kotlin
*.iml
.kotlin/
# IDE
.idea/
*.ipr
*.iws
out/
# OS
.DS_Store
# Local tooling (Magic Context, IDE plugins, MCP configs)
.cortexkit/
.veai/
# Runtime / test artifacts
agentik.db
agentik.db-shm
agentik.db-wal
memory-md/agentik-mem-*/
+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
@@ -0,0 +1,155 @@
# Совместимость с native (linuxX64) — чеклист
Цель: `:standalone` собирается и запускается как `linuxX64` executable (и потенциально
остальные нативные таргеты KMP). Текущее состояние — JVM-only executable на
`runJvm`-таске.
Формат: `- N. [ ]` — задача; `- N. [x]` — выполнена; `- N. [-]` — отменена/не нужна.
Решения (что выбрали и почему) — курсивом внизу пункта.
---
## Блок 1. Подготовка (уже сделана или тривиальная)
- 1. [x] **Замена `java.util.UUID` на KMP-stdlib (`kotlin.uuid.Uuid`).** Helper `Ids.new(prefix)` в `standalone/src/commonMain/.../persistence/Ids.kt`, три call-site (`ChatAgent`, `ChatConversation`, `SqliteWorkingMemoryStore`). Коммит `e816d8d`.
- 2. [x] **HTTP-движок: `ktor-server-netty` → `ktor-server-cio`.** Netty — JVM-only; CIO — KMP (jvm + linuxX64 + ios/...). Каталог-эntry Netty оставлен в `libs.versions.toml` на случай отката. Коммит `e816d8d`.
## Блок 2. Библиотеки litert-kmp (наши)
- 3. [ ] **`litert-api`: добавить `linuxX64()` в `kotlin{}`.**
Сейчас только `androidTarget() + jvm()`. commonMain уже KMP-ready (только
`kotlinx-coroutines-core`). Объём: одна строка в `litert-api/build.gradle.kts`,
один `litert-api-linuxx64`-артефакт публикуется в Nexus.
- 4. [ ] **`litert-openai`: добавить `linuxX64()` в `kotlin{}`.**
Та же история. commonMain deps (`ktor-client-core`, `ktor-client-cio`,
`kotlinx-serialization-json`) — все KMP с готовыми linuxX64-артефактами.
Объём: одна строка + проверить, что Android-only фичи (если есть) изолированы
в `androidMain` (сейчас модуль не имеет `androidMain`-специфичного кода).
*Каталог-запись `litert-openai-jvm` → переименовать в `litert-openai`*
(чтобы Linux-таргет подтягивал `-linuxx64`-вариант автоматически, не нужно
ручного `when target`).
- 5. [-] **`litert-google`: linuxX64 — невозможно.** Upstream-блокер: Google
публикует `litertlm-android` (AAR с `.so`) и `litertlm-jvm` (fat-jar с `.so`
для linux/mac/win JVM), но НЕ нативного `litertlm-linuxx64`. Без правки
upstream LiteRT-LM KMP-враппер для linuxX64 не напишется. Варианты:
(a) выкинуть `litert-google` на linuxX64 (`backend=google` → отказ); (b)
альтернативный движок (llama.cpp через JNI/Kotlin/Native);
(c) upstream PR в `litertlm`. *Решение: (a) для v1 linuxX64 — упасть с
понятной ошибкой `LlmBackend.GOOGLE is not supported on this platform`
вместо reflection-fallback на JVM .so, который на linuxX64 не сработает бы.*
## Блок 3. Наш `:server` модуль
- 6. [x] **Переписать `:server` с `kotlin("jvm")` на `kotlin("multiplatform")`.**
Все 9 целей сборки (jvm + macosX64 + macosArm64 + iosX64 + iosArm64 +
iosSimulatorArm64 + linuxX64 + linuxArm64 + mingwX64) собираются
`./gradlew :server:assemble` без ошибок. Блокеров нет: Ktor
`ktor-server-{core,sse,cio,content-negotiation}` и `ktor-io` (для
`ByteWriteChannel.writeStringUtf8`) есть в KMP-вариантах под все
цели; `kotlin.time.Instant` уже использовался. Единственное
изменение по сравнению с JVM-версией: `respondTextWriter` (JVM-only)
→ `respondBytesWriter` + `writeStringUtf8` (KMP, тот же
`ByteWriteChannel` API).
Источники переехали `src/main/kotlin/...` → `src/commonMain/kotlin/...`.
`:standalone:jvmTest` 44/44 зелёные после миграции.
## Блок 4. Наш `:proto` модуль
- 7. [x] **`:proto`: убрать `kotlinx-datetime` api-dep, оставить `kotlin.time.Instant`.**
Источник уже был на `kotlin.time.Instant` (мигрирован ранее), но
`kotlinx-datetime` оставался в gradle как мёртвая `api`-зависимость.
Удалено:
- `api(libs.kotlinx.datetime)` из `proto/build.gradle.kts`
- `implementation(libs.kotlinx.datetime)` из `standalone/build.gradle.kts`
- `[versions] kotlinx-datetime` и `[libraries] kotlinx-datetime` из
`gradle/libs.versions.toml`
`kotlin.time.Instant` доступен во всех KMP-целях без opt-in.
`./gradlew :server:assemble` + `:standalone:jvmTest` 44/44 — зелёные.
## Блок 5. SQLDelight — нативный драйвер
- 8. [ ] **`sqlite-driver` (JVM/JDBC) → `native-driver` для linuxX64.**
В jvmMain: оставить `sqlite-driver` (JDBC over `java.sql.DriverManager`).
В linuxX64Main: добавить `native-driver` — KMP driver поверх cinterop
с `libsqlite3` (нужен `-lsqlite3` linker-флаг или `SQLiteBundle` из
`co.touchlab:sqliter` для бандлинга). Создать `SqliteStores`-
нейтральный интерфейс хранилищ (он уже есть в `commonMain`), разделить
`jvmMain/SqliteStores.kt` и `linuxX64Main/SqliteStoresNative.kt` —
оба реализуют один `commonMain`-интерфейс. Объём: ~150 строк
(новый `SqliteStoresNative.kt` + gradle wiring).
*Решение по бандлу: использовать `SQLiteBundle` из
`co.touchlab:sqliter` (KMP-обёртка, тянет свой libsqlite) — без
зависимости от системного libsqlite3, бинарь работает на любом linux.*
- 9. [ ] **Тесты SQLite переписать на commonTest + `expect`/`actual` обёртку.**
`inMemory()`-открытие в `native-driver` использует другой API (нет
JDBC `:memory:`). Объём: один `expect fun openInMemory()` в commonMain +
два `actual` (JDBC `jdbc:sqlite::memory:` / native-driver нативный API).
## Блок 6. Внешние либы (`a2a-server`) — наши
- 10. [x] **AGUI убран из проекта.** catalog-entries `agui-api`/`agui-client`/`agui-server`,
`[versions] agui` и `implementation(libs.agui.server)` в `:standalone`
удалены — AGUI больше не нужен (см. Memory #3675 и переписанный
`docs/ARCHITECTURE.md`). AGUI-фасад был stateless request/response,
а agentik переходит на свой stateful протокол `:proto` / `:server`.
- 11. [ ] **`a2a-server`: JVM-only → KMP.** Per Memory #3561 — JVM-only
(client/server; shared уже KMP). Без native-таргета весь стек A2A
недоступен на linuxX64. Объём: переписать на `multiplatform`,
выделить engine-нейтральную маршрутизацию. *Альтернатива: на linuxX64
не подключать a2a-server вообще (только `:server`-фасад). Решение
отложено — зависит от того, нужен ли нативный MCP-юзер-кейс вообще
подключаться к A2A.*
## Блок 7. `:standalone` — добавить native target
- 12. [ ] **`standalone/build.gradle.kts`: добавить `linuxX64()` и
`linuxX64Main`/`linuxX64Test` source-sets.** В `linuxX64Main`:
`:server` (после п.6), `litert-openai` (после п.4), `litert-api`
(после п.3), `ktor-server-cio` (engine), `ktor-client-cio`,
`kotlin-sdk-client` (MCP — KMP, готов), `kotlinx-io` (KMP, готов).
Исключить `litert-google` (п.5) и `a2a-server` (п.11) из linuxX64Main.
- 13. [ ] **`Main.kt`: разделить на `commonMain` (бизнес-логика: создание
ChatAgent, MCP, LlmConfig) + `jvmMain`/`linuxX64Main` (выбор engine,
embeddedServer).** embeddedServer(CIO, …) — один и тот же код на обеих
платформах, KMP-нейтрален. Главное отличие — `SqliteStores.open()` и
обработка GOOGLE-бэкенда на linuxX64 (fail-fast).
## Блок 8. Тесты и CI
- 14. [ ] **`linuxX64Test` source-set.** Покрыть те же 44 теста на
native: должен зелёный прогон через `./gradlew :standalone:linuxX64Test`.
SQLDelight-тесты на native требуют `libsqlite3`/`SQLiteBundle`
на машине (CI-linux предоставляет).
- 15. [ ] **Smoke e2e на linuxX64.** Запустить
`./gradlew :standalone:runLinuxX64` локально, проверить
`POST /agentik/conversations` → 201, цикл с реальным LLM через OpenAI
backend; убедиться что LiteRT-LM GOOGLE не подключается (отказ с
понятной ошибкой).
## Блок 9. Дистрибуция native-бинаря
- 16. [ ] **`runLinuxX64`-таска через KMP binaries DSL.** После добавления
`linuxX64()` KMP генерирует её автоматически — нужно только убедиться
что `binaries { executable { mainClass.set("pw.binom.agentik.standalone.MainKt") } }`
работает для linuxX64 (по аналогии с уже настроенным `runJvm`).
- 17. [ ] **Дистрибутив `.tar`/`.zip`/`AppImage/Docker`.** KMP
`assembleDist` для linuxX64 делает tar.gz/zip. Для прод-дистрибуции —
Docker-образ (`FROM gcr.io/distroless/cc`) или AppImage. Объём:
~50 строк Dockerfile + GitHub Action.
---
## Что НЕ блокирует linuxX64 (закрыто или тривиально)
- `kotlinx-coroutines-core` — KMP linuxX64 ✓
- `kotlinx-serialization-json` — KMP linuxX64 ✓
- `kotlinx-io` — KMP linuxX64 (используется в MCP stdio-transport) ✓
- `kotlin-sdk-client` (MCP) — KMP full targets ✓
- `ktor-server-core`, `ktor-server-sse`, `ktor-server-cio` — KMP linuxX64 ✓
- `ktor-client-core`, `ktor-client-cio` — KMP linuxX64 ✓
- `pw.binom.uuid` — **отсутствует** в проекте (используем `kotlin.uuid`) ✓
## Жёсткие блокеры (не обойти без upstream-работы)
- `litertlm-jvm` → нужен `litertlm-native` от Google (см. п.5)
- `a2a-server` JVM-only (см. п.11) — нужно переписать или явно не подключать
+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.
+43
View File
@@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
// KMP-модуль с ядром механики toolsets: реестр, диспетчер, встроенные тулы
// enable_toolset/disable_toolset. Не зависит от :standalone — может быть
// переиспользован в Android-сборке и в любом другом LiteTool-агенте.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
api(project(":storage-core"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.core)
api(libs.kotlinx.serialization.json)
}
jvmMain.dependencies {
// runBlocking для SyncLiteTool обёртки (LiteTool.invoke — sync)
implementation(libs.kotlin.logging)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* Встроенный тул `disable_toolset` — обратная операция к [EnableToolsetTool].
*
* Контракт (зафиксирован в дизайн-доке):
* - `(member, active)` → `"Toolset 'X' deactivated."`
* - `(member, inactive)` → `"Toolset 'X' deactivated."` (единообразно — как будто был активен)
* - `(unknown, actives exist)` → `"Toolset 'X' not found. Available for deactivation: a, b."`
* - `(unknown, no actives)` → `"Toolset 'X' not found. No toolsets to deactivate."`
*
* Семантика "единообразно как будто был активен" выбрана потому что модель не
* должна различать "он и так был выключен" и "я его выключил" — оба ответа
* означают "сейчас выключен".
*/
class DisableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
val toolset = registry.findByName(name)
if (toolset != null) {
// Единообразный ответ независимо от текущего состояния.
registry.deactivate(name)
return "Toolset '$name' deactivated."
}
// Неизвестный — перечисляем активные (что можно деактивировать)
val actives = registry.activeNames()
return if (actives.isEmpty()) {
"Toolset '$name' not found. No toolsets to deactivate."
} else {
"Toolset '$name' not found. Available for deactivation: ${actives.joinToString(", ")}."
}
}
companion object {
const val NAME: String = "disable_toolset"
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
""".trimIndent()
}
}
@@ -0,0 +1,64 @@
package pw.binom.agentik.toolsets
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.litert.LiteTool
/**
* Встроенный тул `enable_toolset` — модель может им активировать любой
* зарегистрированный тулсет.
*
* Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`):
* - `(member, inactive)` → `"Toolset 'X' activated."`
* - `(member, active)` → `"Toolset 'X' already active."`
* - `(unknown, inactives exist)` → `"Toolset 'X' not found. Available: a, b."`
* - `(unknown, all active)` → `"Toolset 'X' not found. No toolsets available for activation."`
*
* Идемпотентен: повторный enable того же тулсета возвращает
* `"already active"` без сайд-эффектов (поле state не меняется).
*/
class EnableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
val toolset = registry.findByName(name)
if (toolset != null) {
val wasActive = registry.isActive(name)
registry.activate(name)
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
}
// Неизвестный — перечисляем доступные к активации (inactives)
val inactives = registry.inactiveNames()
return if (inactives.isEmpty()) {
"Toolset '$name' not found. No toolsets available for activation."
} else {
"Toolset '$name' not found. Available: ${inactives.joinToString(", ")}."
}
}
companion object {
const val NAME: String = "enable_toolset"
/**
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
* Соответствует litert-kmp формату LiteTool.describe().
*/
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
""".trimIndent()
}
}
/**
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
* Возвращает null если отсутствует или не строка.
*/
internal fun parseName(argsJson: String): String? = runCatching {
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
}.getOrNull()
@@ -0,0 +1,33 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.runBlocking
import pw.binom.litert.LiteTool
/**
* Адаптер из suspend-handler'а в синхронный [LiteTool].
*
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
* неудобство для тулов с асинхронной работой (DB, сеть).
*
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
*
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
*/
internal class SyncLiteTool(
private val describeJson: String,
private val handler: suspend (String) -> String,
) : LiteTool {
override fun describe(): String = describeJson
override fun invoke(arguments: String): String = runBlocking { handler(arguments) }
}
/**
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
* как API-точка чтобы внешний код не зависел от internal-имени класса.
*/
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
SyncLiteTool(describeJson, handler)
@@ -0,0 +1,54 @@
package pw.binom.agentik.toolsets
/**
* Markdown-секция для system prompt, описывающая доступные тулсеты.
*
* Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`):
* - **Если toolsets пустой** → `null` (секция не добавляется, агент не знает
* о механике toolsets вообще; тулы enable_toolset/disable_toolset тоже
* не регистрируются — полная невидимость).
* - **Иначе** → короткое описание концепции + список всех тулсетов
* в формате `name — description`, активные и неактивные одинаково
* (модель видит за что каждый отвечает).
*
* Auto-activation НЕ упоминается в prompt — только в dispatch (если модель
* случайно вызвала тул из выключенного тулсета, диспетчер сам активирует).
* Это чтобы не давать модели ложную опцию "не буду enable, а просто вызову".
*/
object SystemPromptToolsetSection {
fun render(
active: List<ToolsetContribution>,
inactive: List<ToolsetContribution>,
): String? {
if (active.isEmpty() && inactive.isEmpty()) return null
return buildString {
appendLine("## Toolsets")
appendLine()
appendLine("Toolsets group related tools. Use enable_toolset to activate one; its tools become available. Use disable_toolset to deactivate.")
appendLine()
if (active.isNotEmpty()) {
appendLine("Active:")
for (c in active) appendLine("- ${c.name} — ${c.description}")
appendLine()
}
if (inactive.isNotEmpty()) {
appendLine("Inactive:")
for (c in inactive) appendLine("- ${c.name} — ${c.description}")
appendLine()
}
}.trim()
}
/**
* Convenience: рендер по [ToolsetRegistry] (синхронный — без activeTools(),
* только имена и описания).
*/
fun render(registry: ToolsetRegistry, activeNames: Set<String>): String? {
val all = registry.all()
if (all.isEmpty()) return null
val active = all.filter { it.name in activeNames }
val inactive = all.filter { it.name !in activeNames }
return render(active, inactive)
}
}
@@ -0,0 +1,40 @@
package pw.binom.agentik.toolsets
/**
* Контекст, который тулсеты получают при активации.
*
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
* могли читать/писать сообщения и память.
*
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
* параметризованный factory и принимать остальное извне — [ToolsetContext]
* остаётся минимальным ядром.
*/
interface ToolsetContext {
val logger: Logger
}
/**
* No-op логгер по умолчанию. Передаётся в [ToolsetRegistry], если внешний код
* не предоставил свой (например, в тестах или при работе из CLI без logging
* конфигурации).
*/
object NoOpLogger : Logger {
override fun debug(msg: String) {}
override fun info(msg: String) {}
override fun warn(msg: String) {}
override fun error(msg: String, ex: Throwable?) {}
}
/**
* Минимальный logger-интерфейс для тулсетов. Совместим по сигнатуре с
* `kotlin-logging`'s `KLogger` и `org.slf4j.Logger` — внешний код может
* передать адаптер из любого.
*/
interface Logger {
fun debug(msg: String)
fun info(msg: String)
fun warn(msg: String)
fun error(msg: String, ex: Throwable? = null)
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* Декларация одного тулсета: имя, описание (видимое модели в system prompt),
* и список входящих тулов.
*
* Toolset — это группа инструментов, которые модель может включить или
* выключить через `enable_toolset` / `disable_toolset`. Модель не получает
* тулы неактивного тулсета напрямую; если она случайно вызовет тул из
* выключенного тулсета, диспетчер молча его включает (прощающая семантика).
*
* @property name уникальное имя тулсета (например, `"media"`).
* @property description короткое описание что тулсет делает; показывается в
* system prompt чтобы модель могла решить, какой тулсет включить.
* @property tools список [LiteTool]-ов, которые становятся доступны когда
* тулсет активен. У каждого тула `toolName` используется для поиска владельца
* при диспетчеризации.
*/
data class ToolsetContribution(
val name: String,
val description: String,
val tools: List<ToolEntry>,
) {
/**
* Один инструмент в составе тулсета.
*
* @property toolName стабильное имя тула (должно совпадать с `name` полем
* в JSON-дескрипторе тула, иначе диспетчер его не найдёт).
* @property tool сам [LiteTool] — синхронный интерфейс litert-kmp.
*/
data class ToolEntry(val toolName: String, val tool: LiteTool)
}
@@ -0,0 +1,133 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import pw.binom.litert.LiteTool
/**
* Тип диспетчера "плоских" тулов (не из тулсетов). Принимает имя тула и
* сырые JSON-аргументы строкой, возвращает результат строкой.
*
* Используется [ToolsetDispatchPolicy] как fallback: если тул не найден ни в
* одном активном/неактивном тулсете, диспетчер передаёт его в 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 = (toolName: String, argumentsJson: String) -> String
/**
* Диспетчер вызовов тулов с учётом тулсетов.
*
* Алгоритм при вызове `dispatch(toolName, args)`:
* 1. **Активный тул** — тул с таким именем есть в одном из активных тулсетов.
* Выполняем напрямую, возвращаем результат. Outcome: `Ran`.
* 2. **Неактивный тул** — тул принадлежит зарегистрированному (но неактивному)
* тулсету. Молча активируем тулсет, выполняем тул. Outcome: `Ran`.
* 3. **Неизвестный тул** — нет ни в одном тулсете. Передаём в [baseDispatcher]
* (там живут плоские тулы вроде `memory_save`). Outcome: `Ran` или `Failed`
* — зависит от того, что вернёт base.
*
* Прощающая 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,
private val baseDispatcher: BaseToolDispatcher,
) {
sealed interface Outcome {
/** Тул выполнен успешно. */
data class Ran(
val toolsetName: String?,
val toolName: String,
val result: String,
) : Outcome
/** Тул не найден ни в одном тулсете, и base dispatcher его тоже не знает. */
data class Unknown(val toolName: String, val reason: String) : Outcome
}
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)
}
// 2. Принадлежит зарегистрированному тулсету (auto-activate)?
val ownerPair = registry.findOwnerByToolName(toolName)
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.
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) {
val contribution = registry.findByName(name) ?: continue
if (contribution.tools.any { it.toolName == toolName }) return name
}
return null
}
}
/**
* Извлекает имя тула из его JSON-дескриптора. LiteTool — стандартизированный
* формат (см. litert-kmp LiteTool), где JSON содержит поле `"name"`.
*
* Используется для матчинга имени тула (которое модель передаёт в
* `tool_calls`) с фактическим LiteTool-ом (у которого имени нет в API).
*
* При ошибке парсинга возвращает пустую строку — диспетчер просто не найдёт
* такой тул, что безопасно (уйдёт в fallback).
*/
internal fun LiteTool.nameFromDescribe(): String {
val json = runCatching { describe() }.getOrNull() ?: return ""
return runCatching {
kotlinx.serialization.json.Json.parseToJsonElement(json)
.jsonObject["name"]?.jsonPrimitive?.content ?: ""
}.getOrDefault("")
}
private val kotlinx.serialization.json.JsonElement.jsonObject
get() = (this as kotlinx.serialization.json.JsonObject)
private val kotlinx.serialization.json.JsonElement.jsonPrimitive
get() = (this as kotlinx.serialization.json.JsonPrimitive)
@@ -0,0 +1,115 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.litert.LiteTool
/**
* Реестр тулсетов: хранит список доступных [ToolsetContribution]-ов и
* отслеживает, какие из них сейчас активны.
*
* Потокобезопасен (`Mutex` вокруг всех мутаций). Один экземпляр на агента —
* разделяется между ChatAgent и диспетчером.
*
* Диспетчер тулов (см. [ToolsetDispatchPolicy]) использует [findOwnerByToolName]
* чтобы:
* 1. Найти активный тул по имени — диспетчировать напрямую.
* 2. Если тул принадлежит неактивному тулсету — молча его активировать.
* 3. Если тул вообще не найден — передать в fallback-диспетчер
* (для «плоских» тулов вне toolsets).
*
* Модель может явно управлять состоянием через тулы `enable_toolset` /
* `disable_toolset` (см. [EnableToolsetTool], [DisableToolsetTool]).
*/
class ToolsetRegistry(
private val contributions: List<ToolsetContribution>,
private val context: ToolsetContext = NoOpToolsetContext,
) : AutoCloseable {
private val active: MutableSet<String> = mutableSetOf()
private val lock = Mutex()
/** Все зарегистрированные тулсеты (read-only). */
fun all(): List<ToolsetContribution> = contributions
/** Имена всех зарегистрированных тулсетов (для prompt section и диагностики). */
fun names(): List<String> = contributions.map { it.name }
/** Найти тулсет по имени (или null). */
fun findByName(name: String): ToolsetContribution? =
contributions.firstOrNull { it.name == name }
/**
* Найти тулсет, владеющий тулом с данным именем. Перебирает все
* зарегистрированные тулсеты, у каждого смотрит [ToolsetContribution.tools].
*
* Используется диспетчером для auto-activation: если модель вызвала тул из
* неактивного тулсета — мы молча его активируем и выполняем.
*/
fun findOwnerByToolName(toolName: String): Pair<ToolsetContribution, ToolsetContribution.ToolEntry>? {
for (c in contributions) {
val entry = c.tools.firstOrNull { it.toolName == toolName }
if (entry != null) return c to entry
}
return null
}
suspend fun isActive(name: String): Boolean = lock.withLock { active.contains(name) }
/**
* Активировать тулсет. Если уже активен — no-op. Возвращает `true`, если
* состояние изменилось (т.е. тулсет был неактивен и теперь активен).
*/
suspend fun activate(name: String): Boolean = lock.withLock {
active.add(name)
}
/**
* Деактивировать тулсет. Если и так неактивен — no-op. Возвращает `true`,
* если состояние изменилось.
*/
suspend fun deactivate(name: String): Boolean = lock.withLock {
active.remove(name)
}
suspend fun activeNames(): List<String> = lock.withLock { active.toList() }
suspend fun inactiveNames(): List<String> = lock.withLock {
contributions.map { it.name }.filter { it !in active }
}
/**
* Список всех активных тулов (для передачи в LiteConversationConfig.tools).
* Вызывает `LiteTool.describe()` каждого тула — безопасно для типичных
* stateless тулов.
*/
suspend fun activeTools(): List<LiteTool> {
val activeNames = activeNames()
return activeNames.mapNotNull { name ->
val contribution = findByName(name)
contribution?.tools?.map { it.tool }
}.flatten()
}
/** Тулсет-контекст, который передан конструктору. */
fun context(): ToolsetContext = context
override fun close() {
// no-op: нет внешних ресурсов. Сделано для удобства AutoCloseable-конвенции.
}
companion object {
/** Пустой реестр без единого тулсета. */
fun empty(context: ToolsetContext = NoOpToolsetContext): ToolsetRegistry =
ToolsetRegistry(emptyList(), context)
}
}
/**
* Дефолтный контекст для случая, когда внешний код не передал свой. Использует
* no-op логгер — события тулсетов (activate/deactivate/auto-activate) не
* пишутся никуда. Для prod-запуска передайте контекст с настоящим логгером.
*/
private val NoOpToolsetContext = object : ToolsetContext {
override val logger: Logger = NoOpLogger
}
@@ -0,0 +1,77 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
class DisableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok"
}
private fun harness(toolsets: List<ToolsetContribution>): Pair<DisableToolsetTool, ToolsetRegistry> {
val r = ToolsetRegistry(toolsets)
return DisableToolsetTool(r) to r
}
@Test
fun `describe contains expected name and parameters`() {
val (disable, _) = harness(emptyList())
val desc = disable.tool.describe()
assertEquals(true, desc.contains("\"name\":\"disable_toolset\""))
assertEquals(true, desc.contains("\"parameters\""))
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
}
@Test
fun `deactivating an active toolset returns deactivated message`() = runTest {
val (disable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = disable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' deactivated.", r)
assertEquals(false, reg.isActive("media"))
}
@Test
fun `deactivating an inactive toolset returns the same uniform message`() = runTest {
val (disable, _) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
val r = disable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' deactivated.", r)
}
@Test
fun `deactivating unknown toolset with actives returns available actives`() = runTest {
val (disable, reg) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
ToolsetContribution("b", "y", emptyList()),
))
reg.activate("a")
reg.activate("b")
val r = disable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
}
@Test
fun `deactivating unknown toolset with no actives returns no-toolsets message`() = runTest {
val (disable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
))
val r = disable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
}
@Test
fun `missing name argument returns error message`() = runTest {
val (disable, _) = harness(emptyList())
val r = disable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
}
@@ -0,0 +1,76 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
class EnableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok"
}
private fun harness(toolsets: List<ToolsetContribution>): Pair<EnableToolsetTool, ToolsetRegistry> {
val r = ToolsetRegistry(toolsets)
return EnableToolsetTool(r) to r
}
@Test
fun `describe contains expected name and parameters`() {
val (enable, _) = harness(emptyList())
val desc = enable.tool.describe()
assertEquals(true, desc.contains("\"name\":\"enable_toolset\""))
assertEquals(true, desc.contains("\"parameters\""))
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
}
@Test
fun `activating a registered toolset returns activated message`() = runTest {
val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
))
val r = enable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' activated.", r)
assertEquals(true, reg.isActive("media"))
}
@Test
fun `activating an already active toolset returns already-active message`() = runTest {
val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = enable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' already active.", r)
}
@Test
fun `activating unknown toolset lists available inactives`() = runTest {
val (enable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
ToolsetContribution("b", "y", emptyList()),
ToolsetContribution("c", "z", emptyList()),
))
val r = enable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
}
@Test
fun `activating unknown toolset with no inactives returns no-toolsets message`() = runTest {
val (enable, reg) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
))
reg.activate("a")
val r = enable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
}
@Test
fun `missing name argument returns error message`() = runTest {
val (enable, _) = harness(emptyList())
val r = enable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
}
@@ -0,0 +1,87 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import pw.binom.litert.LiteTool
class SystemPromptToolsetSectionTest {
private fun stub(name: String, desc: String): ToolsetContribution = ToolsetContribution(
name = name,
description = desc,
tools = emptyList(), // prompt section не зависит от tools
)
@Test
fun `empty lists return null - section is omitted entirely`() {
assertNull(SystemPromptToolsetSection.render(emptyList(), emptyList()))
}
@Test
fun `only active present - omits inactive header`() {
val section = SystemPromptToolsetSection.render(
active = listOf(stub("a", "first toolset")),
inactive = emptyList(),
)
assertEquals(true, section!!.contains("## Toolsets"))
assertEquals(true, section.contains("Active:"))
assertEquals(true, section.contains("- a — first toolset"))
assertEquals(false, section.contains("Inactive:"))
}
@Test
fun `only inactive present - omits active header`() {
val section = SystemPromptToolsetSection.render(
active = emptyList(),
inactive = listOf(stub("b", "second toolset")),
)
assertEquals(true, section!!.contains("## Toolsets"))
assertEquals(true, section.contains("Inactive:"))
assertEquals(true, section.contains("- b — second toolset"))
assertEquals(false, section.contains("\nActive:"))
}
@Test
fun `both active and inactive - renders both blocks`() {
val section = SystemPromptToolsetSection.render(
active = listOf(stub("a", "first")),
inactive = listOf(stub("b", "second"), stub("c", "third")),
)
assertEquals(true, section!!.contains("- a — first"))
assertEquals(true, section.contains("- b — second"))
assertEquals(true, section.contains("- c — third"))
}
@Test
fun `does not mention auto-activation - per design contract`() {
val section = SystemPromptToolsetSection.render(
active = emptyList(),
inactive = listOf(stub("a", "x")),
)!!
// Дизайн-док: auto-activation НЕ в промпте (только в dispatch)
assertEquals(false, section.contains("auto", ignoreCase = true))
assertEquals(false, section.contains("автоматическ", ignoreCase = true))
}
@Test
fun `registry-based render filters by active names`() = runTest {
val reg = ToolsetRegistry(listOf(
stub("a", "first"),
stub("b", "second"),
))
reg.activate("a")
val section = SystemPromptToolsetSection.render(reg, setOf("a"))
assertEquals(true, section!!.contains("- a — first"))
assertEquals(true, section.contains("- b — second"))
assertEquals(true, section.contains("Active:"))
assertEquals(true, section.contains("Inactive:"))
}
@Test
fun `empty registry renders null`() = runTest {
val reg = ToolsetRegistry.empty()
assertNull(SystemPromptToolsetSection.render(reg, setOf()))
}
}
@@ -0,0 +1,99 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class ToolsetDispatchPolicyTest {
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response
}
private fun ts(name: String, toolNames: List<String>): ToolsetContribution = ToolsetContribution(
name = name,
description = "toolset $name",
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
)
/** Helper: build policy + expose its registry для assert-ов в тестах. */
private class Harness(
val policy: ToolsetDispatchPolicy,
val registry: ToolsetRegistry,
)
private fun harness(
toolsets: List<ToolsetContribution>,
baseKnown: Set<String> = setOf("memory_save"),
): Harness {
val registry = ToolsetRegistry(toolsets)
val base: BaseToolDispatcher = { n, a ->
if (n in baseKnown) "base:$n:$a" else error("unknown base tool: $n")
}
return Harness(ToolsetDispatchPolicy(registry, base), registry)
}
@Test
fun `active tool is dispatched directly`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
h.registry.activate("media")
val outcome = h.policy.dispatch("resize_image", "{}")
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
assertEquals("media", ran.toolsetName)
assertEquals("resize_image", ran.toolName)
assertEquals("ok:resize_image", ran.result)
}
@Test
fun `inactive tool triggers auto-activation`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
assertFalse(h.registry.isActive("media"))
val outcome = h.policy.dispatch("resize_image", "{}")
assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
// auto-activation: тулсет теперь активен
assertTrue(h.registry.isActive("media"))
}
@Test
fun `unknown tool falls through to base dispatcher`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
val outcome = h.policy.dispatch("memory_save", """{"key":"value"}""")
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
assertEquals(null, ran.toolsetName)
assertEquals("memory_save", ran.toolName)
assertEquals("base:memory_save:{\"key\":\"value\"}", ran.result)
}
@Test
fun `inactive tool wins over base fallback for shared name`() = runTest {
// Тулу "shared" принадлежит тулсет (inactive), и в base диспетчере тоже
// есть "shared". Должен победить тулсет (с auto-activation), не base.
val h = harness(
listOf(ts("ts", listOf("shared"))),
baseKnown = setOf("shared"),
)
val outcome = h.policy.dispatch("shared", "{}")
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
assertEquals("ts", ran.toolsetName)
assertEquals("ok:shared", ran.result)
assertTrue(h.registry.isActive("ts"))
}
@Test
fun `completely unknown tool bubbles up from base dispatcher`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
// base dispatcher бросает IllegalStateException — это распространяется
// через suspend и попадает в вызывающий код. Это OK: вызывающий код
// (ChatAgent) ловит исключения тулов и формирует tool_result с ошибкой.
val ex = runCatching {
kotlinx.coroutines.runBlocking { h.policy.dispatch("totally_unknown_tool", "{}") }
}.exceptionOrNull()
assertTrue(ex is IllegalStateException, "expected ISE, got $ex")
assertTrue(ex.message!!.contains("totally_unknown_tool"))
}
}
@@ -0,0 +1,131 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ToolsetRegistryTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok:$name"
}
private fun contribution(
name: String,
description: String = "test toolset",
toolNames: List<String> = listOf("tool1"),
): ToolsetContribution = ToolsetContribution(
name = name,
description = description,
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
)
@Test
fun `empty registry has no active tools`() = runTest {
val r = ToolsetRegistry.empty()
assertEquals(emptyList(), r.activeNames())
assertEquals(emptyList(), r.activeTools())
}
@Test
fun `all returns registered contributions`() {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
assertEquals(listOf("a", "b"), r.names())
}
@Test
fun `findByName returns matching contribution or null`() {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
assertNotNull(r.findByName("a"))
assertEquals("test toolset", r.findByName("a")?.description)
assertNull(r.findByName("nope"))
}
@Test
fun `activate changes state and isActive reports true`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a")))
assertFalse(r.isActive("a"))
r.activate("a")
assertTrue(r.isActive("a"))
assertEquals(listOf("a"), r.activeNames())
}
@Test
fun `activate is idempotent - second call is no-op`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a")))
r.activate("a")
r.activate("a")
assertEquals(1, r.activeNames().size)
}
@Test
fun `deactivate removes from active`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
r.activate("a")
r.activate("b")
r.deactivate("a")
assertEquals(listOf("b"), r.activeNames())
assertFalse(r.isActive("a"))
}
@Test
fun `deactivate on inactive is no-op`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a")))
r.deactivate("a") // never activated
assertEquals(emptyList(), r.activeNames())
}
@Test
fun `inactiveNames returns the complement of active`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b"), contribution("c")))
r.activate("a")
r.activate("c")
assertEquals(listOf("b"), r.inactiveNames())
}
@Test
fun `activeTools returns the LiteTool instances from active toolsets`() = runTest {
val r = ToolsetRegistry(listOf(
contribution("ts1", toolNames = listOf("t1", "t2")),
contribution("ts2", toolNames = listOf("t3")),
))
r.activate("ts1")
r.activate("ts2")
val tools = r.activeTools()
assertEquals(3, tools.size)
// Проверяем что имена извлекаются из describe()
val names = tools.map { it.nameFromDescribe() }.toSet()
assertEquals(setOf("t1", "t2", "t3"), names)
}
@Test
fun `findOwnerByToolName locates the owning toolset`() {
val r = ToolsetRegistry(listOf(
contribution("ts1", toolNames = listOf("t1", "t2")),
contribution("ts2", toolNames = listOf("t3")),
))
val owner = r.findOwnerByToolName("t2")
assertNotNull(owner)
assertEquals("ts1", owner.first.name)
assertEquals("t2", owner.second.toolName)
}
@Test
fun `findOwnerByToolName returns null for unknown tool`() {
val r = ToolsetRegistry(listOf(contribution("ts1", toolNames = listOf("t1"))))
assertNull(r.findOwnerByToolName("nonexistent"))
}
@Test
fun `close is idempotent and does nothing`() {
val r = ToolsetRegistry.empty()
r.close()
r.close()
}
}
+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)")
+141
View File
@@ -0,0 +1,141 @@
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
// maven-publish — стандартный плагин Gradle, объявлен apply'ем в subprojects ниже.
}
group = "pw.binom.agentik"
// projectVersion определяется ниже как val, чтобы subprojects могли его
// прочитать через rootProject.extra["projectVersion"].
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
// gradle.properties). Без версии maven-publish падает с
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
// это известный gotcha: subprojects читают rootProject.version ДО того, как
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
// eagerly, и subprojects получают готовую строку.
val projectVersion: String = providers.gradleProperty("version")
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
.getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
version = projectVersion
extra["projectVersion"] = projectVersion
// Home Nexus URL/creds — передаются через -Pbinom.repo.* из CI/CD workflow
// (.gitea/workflows/release.yml). Локально для дебага:
// ./gradlew publish -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
// Без -P URL падает на дефолтный placeholder (заглушка для локальной разработки).
val binomRepoUrl = (findProperty("binom.repo.url") as String? ?: "http://nexus.xx/repository/caffeine/").toString()
val binomRepoUser = (findProperty("binom.repo.user") as String? ?: "").toString()
val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").toString()
// Per-module POM description. Один источник истины — карта ниже,
// лишнее в settings.gradle.kts держим в комментарии-зеркале.
// При добавлении нового модуля — добавь строку сюда + README.md в его корень.
// Кладём в rootProject.extra ДО apply KMP-плагина в subprojects (beforeEvaluate
// срабатывает позже, чем apply плагина, поэтому просто положить extra в
// beforeEvaluate — поздно).
val moduleDescriptions: Map<String, String> = mapOf(
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
"memory-api" to "agentik :memory-api — интерфейсы долговременной памяти (MemoryStore, MemoryCategory, MemoryNote).",
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
"agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (desktop, без iOS) с клавиатурной навигацией без ':'-префиксов.",
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
)
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
subprojects {
group = rootProject.group
// KMP-плагин читает project.version на ранней стадии evaluation — ДО того
// как сработает внешний subprojects-блок. Если version ещё "unspecified",
// publication 'kotlinMultiplatform' создаётся с пустой version, и тогда
// maven-publish падает с 'InvalidMavenPublicationException: version cannot
// be empty'. Поэтому:
// 1) eagerly переопределяем version в rootProject.extra (см. выше)
// 2) на КАЖДЫЙ subproject вешаем beforeEvaluate, который выставляет
// version до того, как KMP-плагин начнёт создавать publications.
// per-module POM description берётся из rootProject.extra["moduleDescriptions"]
// (см. корень build.gradle.kts); добавлять новый модуль — туда + README.md.
// beforeEvaluate срабатывает ДО apply плагинов в build.gradle.kts модуля, так
// что version/description уже валидны, когда KMP-плагин начинает создавать
// publications.
beforeEvaluate {
description = (rootProject.extra["moduleDescriptions"] as Map<String, String>)[project.name]
?: "agentik module: ${project.name}"
version = rootProject.extra["projectVersion"] as String
}
apply(plugin = "maven-publish")
extensions.configure<PublishingExtension>("publishing") {
repositories {
maven {
name = "caffeine"
url = uri(binomRepoUrl)
isAllowInsecureProtocol = true
credentials {
username = binomRepoUser
password = binomRepoPassword
}
}
}
// Per-subproject POM-метаданные (name, scm, licenses, developers).
// Также явно выставляем version/group для каждой публикации. В KMP-модулях
// (особенно JVM-only с одним jvm() target) kotlin-multiplatform plugin
// создаёт publication 'kotlinMultiplatform' на ранней стадии evaluation,
// когда project.version ещё 'unspecified'. Простое присваивание
// subprojects { version = ... } НЕ перезаписывает уже зафиксированную
// version в publication → InvalidMavenPublicationException в CI.
// Явная установка version здесь гарантирует, что публикация всегда
// использует актуальное значение из rootProject.extra.
publications.withType<MavenPublication>().configureEach {
groupId = rootProject.group.toString()
artifactId = project.name
version = rootProject.extra["projectVersion"] as String
pom {
name = project.name
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
?: "agentik module: ${project.name}"
url = "https://git.binom.pw/subochev/agentik"
licenses {
license {
name = "Apache-2.0"
url = "https://www.apache.org/licenses/LICENSE-2.0"
}
}
developers {
developer {
id = "subochev"
name = "Subochev Alexey"
url = "https://git.binom.pw/subochev"
}
}
scm {
connection = "scm:git:https://git.binom.pw/subochev/agentik.git"
developerConnection = "scm:git:ssh://git@git.binom.pw/subochev/agentik.git"
url = "https://git.binom.pw/subochev/agentik"
}
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
## Что это
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
интерфейсы `:proto`:
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
- `AgentClient` — список и lifecycle диалогов.
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
`getMessages()`, `rename()`, `close()`.
- Внутренний парсер SSE → `Flow<Event>`.
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
с теми же типами, что и сервер. Один и тот же клиент работает на
JVM, iOS, macOS, Linux, Windows.
## Где используется
- `:agentik-cli` — REPL.
- `:agentik-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.
+25
View File
@@ -0,0 +1,25 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
implementation(project(":proto"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/**
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
*
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
* POST `/conversations`. Используем `runBlocking` — это одноразовая
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
* решает, что делать.
*/
internal class AgentClient(
private val httpClient: HttpClient,
private val baseUrl: String,
override val id: String,
) : Agent {
private val agentUrl: String = baseUrl.trimEnd('/')
override fun createConversation(temp: Boolean): Conversation =
runBlocking {
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
contentType(ContentType.Application.Json)
setBody(RequestCreateConversation(temp))
}.body()
ConversationClient(httpClient = httpClient, baseUrl = agentUrl, snapshot = snapshot)
}
override suspend fun getConversation(id: String): Conversation? {
val response = httpClient.get("$agentUrl/conversations/$id")
if (response.status == HttpStatusCode.NotFound) return null
val snapshot = response.body<ConversationSnapshot>()
return ConversationClient(httpClient, agentUrl, snapshot)
}
override suspend fun deleteConversation(id: String): Boolean {
val response = httpClient.delete("$agentUrl/conversations/$id")
return response.status == HttpStatusCode.NoContent
}
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
val snapshots = httpClient.get("$agentUrl/conversations") {
parameter("offset", offset)
parameter("limit", limit)
}.body<List<ConversationSnapshot>>()
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
}
override fun events(after: Instant): Flow<AgentEvent> = flow {
val response = httpClient.get("$agentUrl/events?after=$after")
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
}
}
@@ -0,0 +1,43 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import pw.binom.agentik.proto.Agent
/**
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
* (модуль `:server`).
*
* ```
* val client = AgentikAgent(
* id = "my-agent",
* baseUrl = "http://localhost:8080/agentik",
* )
* val conv = client.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi")))
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
* ```
*
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
* агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига).
*
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
*/
fun AgentikAgent(
id: String,
baseUrl: String,
httpClient: HttpClient = defaultAgentikHttpClient(),
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
/**
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
* (см. [readSse]) живёт в общем коде и плагина не требует.
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(agentikJson) }
}
@@ -0,0 +1,102 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant
/**
* HTTP-реализация [Conversation]. Ходит в `:server`-фасад под
* `/conversations/{id}/...`.
*
* `id` отдаётся синхронно (он в [snapshot], доступном сразу). Остальные
* поля (`title`, `isSupportImageInput`, ...) — тоже из snapshot. [snapshot]
* обновляется после [rename] (сервер возвращает свежий).
*
* **Caveat — `updatedAt`:** сервер бампит `updatedAt` на каждый
* `send`/`rename`, но клиент узнает об этом только при следующем
* `rename` или `getConversation`. Если нужна свежая свежесть после
* `send` — перезапроси через `Agent.getConversation(id)`.
*
* [close] — локальный no-op: сервер держит диалог живым. Удалить —
* через `Agent.deleteConversation(id)`.
*/
internal class ConversationClient(
private val httpClient: HttpClient,
private val baseUrl: String,
private var snapshot: ConversationSnapshot,
) : Conversation {
override val id: String get() = snapshot.id
override val title: String? get() = snapshot.title
override val isSupportImageInput: Boolean get() = snapshot.isSupportImageInput
override val isSupportImageOutput: Boolean get() = snapshot.isSupportImageOutput
override val isTemporal: Boolean get() = snapshot.isTemporal
override val updatedAt: Instant get() = snapshot.updatedAt
private val convUrl: String get() = "$baseUrl/conversations/$id"
override suspend fun rename(title: String) {
val updated = httpClient.patch(convUrl) {
contentType(ContentType.Application.Json)
setBody(RequestRename(title))
}.body<ConversationSnapshot>()
snapshot = updated
}
override suspend fun send(content: List<Content>, context: MessageContext?) {
httpClient.post("$convUrl/messages") {
contentType(ContentType.Application.Json)
setBody(SendPayload(content, context))
}
}
override suspend fun interrupt() {
httpClient.post("$convUrl/interrupt")
}
override fun events(after: Instant): Flow<Event> = flow {
val response = httpClient.get("$convUrl/events?after=$after")
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
httpClient.get("$convUrl/messages") {
parameter("after", after.toString())
parameter("offset", offset)
parameter("limit", limit)
}.body()
override fun close() {
// Локальный no-op: диалог на сервере живёт, пока не вызван
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
}
}
@Serializable
private data class SendPayload(
val content: List<Content>,
val context: MessageContext? = null,
)
@@ -0,0 +1,26 @@
package pw.binom.agentik.client
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* HTTP-снимок [pw.binom.agentik.proto.Conversation] — те же поля, что у
* интерфейса, но без методов. Зеркалит
* [pw.binom.agentik.server.ConversationSnapshot]. Дубликат сознательно:
* переедем в общий `:wire`, когда появится больше типов.
*/
@Serializable
data class ConversationSnapshot(
val id: String,
val isSupportImageInput: Boolean,
val isSupportImageOutput: Boolean,
val isTemporal: Boolean,
val title: String? = null,
val updatedAt: Instant,
)
@Serializable
internal data class RequestCreateConversation(val temp: Boolean)
@Serializable
internal data class RequestRename(val title: String)
@@ -0,0 +1,41 @@
package pw.binom.agentik.client
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlin.time.Instant
/**
* Зеркалит [pw.binom.agentik.server.InstantSerializer]. Дублируем сознательно:
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
*/
internal object InstantSerializer : KSerializer<Instant> {
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant =
Instant.parse(decoder.decodeString())
}
/**
* JSON-конфиг клиента. Должен **точно** совпадать с серверным `agentikJson` —
* один и тот же wire-формат с обеих сторон.
*/
internal val agentikJson: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
serializersModule = SerializersModule {
contextual(Instant::class, InstantSerializer)
}
}
@@ -0,0 +1,45 @@
package pw.binom.agentik.client
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* Минимальный парсер Server-Sent Events, читающий канал до EOF и эмиттящий
* собранный `data:`-пейлоад каждого события. Достаточно для нашего wire-формата:
* сервер шлёт `data: <json>\n\n`, имя события и прочие поля не используются.
*
* Формат (см. WHATWG):
* event: foo — игнор (у нас нет имён событий)
* data: {"k":1} — накапливается, многострочный `data:` склеивается через '\n'
* :comment — игнор
* id:/retry:/<blank> — пустая строка = граница события; всё остальное игнор
*
* Поток закрывается, когда канал доходит до EOF; накопленный `data` (если есть)
* эмитится как финальный ивент.
*/
internal fun readSse(channel: ByteReadChannel): Flow<String> = flow {
val data = StringBuilder()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: break
when {
line.isEmpty() -> {
if (data.isNotEmpty()) {
emit(data.toString())
data.clear()
}
}
line.startsWith("data: ") -> {
if (data.isNotEmpty()) data.append('\n')
data.append(line.removePrefix("data: "))
}
line.startsWith("data:") -> {
if (data.isNotEmpty()) data.append('\n')
data.append(line.removePrefix("data:"))
}
// event:, id:, retry:, ":" (comment) — игнорируем
}
}
if (data.isNotEmpty()) emit(data.toString())
}
+168
View File
@@ -0,0 +1,168 @@
# Agentik — архитектура
> Полевые заметки о структуре проекта на текущий момент.
> Подробности запуска — `docs/STANDALONE.md`, чек-лист native-сборки —
> `NATIVE-COMPATIBILITY.md`, открытые вопросы — `IRC-QUESTIONS.md`.
## 1. Контекст
agentik — runtime агента. Ядро на собственном протоколе (`:proto`),
HTTP/SSE-фасад в `:server`, runtime-контейнер в `:standalone`. Раньше
фасадов было два (AG-UI + A2A); AG-UI убран как устаревший,
`a2a-server` остаётся заготовкой в `libs.versions.toml` и подключён в
`:standalone` `build.gradle.kts`, но в `Main.kt` пока не монтируется.
LLM — за интерфейсом `pw.binom.litert.LiteLlm`. Реализации:
`pw.binom.litert.openai` (HTTP/JSON поверх OpenAI-API, любая
совместимая endpoint) и `pw.binom.litert.google` (встроенный
LiteRT-LM движок для `.litertlm`/`.task` моделей).
MCP — `io.modelcontextprotocol:kotlin-sdk-client` (KMP). Подключаются
stdio + streamable-HTTP серверы, тулы оборачиваются в `LiteTool`.
## 2. Модули
```
agentik
├─ :proto KMP jvm + native. Интерфейсы Agent/Conversation, Content,
│ Message, Event, AgentEvent. @SerialName
│ дискриминаторы snake_case на проводе.
├─ :server KMP jvm + native. HTTP/SSE фасад `Route.agentikAgent(agent,
│ path = "/agentik")`. Json DTO — свой
│ Snapshot-тип, чтобы протокол оставался
│ сериализационно-чистым.
├─ :client JVM-only (пока). Ktor-клиент к фасаду `:server`,
│ подключающий удалённый агент как
│ локальный `Agent`.
└─ :standalone KMP jvm (linuxX64 — в работе). Runnable-контейнер:
SqliteStores + ChatAgent + MCP + LiteLlm,
поднимает Ktor (CIO) на AGENTIK_PORT.
```
Каталог версий — `gradle/libs.versions.toml`. Из внешних — только
`pw.binom.litert.*`, `pw.binom.a2a.*`, `app.cash.sqldelight`,
`io.ktor:*`, `io.modelcontextprotocol:kotlin-sdk-client`,
`org.jetbrains.kotlinx:*`.
## 3. `:proto` — интерфейсы
`Agent` (см. `proto/src/commonMain/.../Agent.kt`):
- `id: String`
- `createConversation(temp: Boolean): Conversation`
- `suspend getConversation(id): Conversation?`
- `suspend getConversations(offset, limit): List<Conversation>`
- `getConversations(offset = 0): Flow<Conversation>` — cold-flow paging через suspend-версию, `PAGE_SIZE = 100`.
- `events(after: Instant): Flow<AgentEvent>` — replay-free, бэкфилл через snapshot.
- `deleteConversation(id): Boolean`
`Conversation`:
- `isSupportImageInput / Output / isTemporal: Boolean`
- `updatedAt: Instant`
- `send(content: List<Content>)` — write-only, ничего не возвращает.
- `interrupt()` — отмена активного хода.
- `events(after): Flow<Event>` — live, replay-free.
- `getMessages(after, offset, limit)` + `getMessages(after): Flow<Message>` — paging.
- `rename(title)` — мутация, бампит `updatedAt`.
- `AutoCloseable` — `close()` идемпотентен.
`Content = Text(body) | Image(data, mime)`.
`Message = UserMessage | AssistantMessage | ToolCall | ToolResult | Error`.
`Event = StartReasoning | StartResponse | End | AppendText | AppendImage | ToolCall | ToolResult | Error`.
`AgentEvent = Created(conversationId) | Deleted(id) | Renamed(id, title)`.
Принцип: **агент — источник истины** для транскрипта и сессий. Клиент
лишь рендерит Event-stream и кэширует историю.
## 4. `:standalone` — runtime-контейнер
Точка входа `pw.binom.agentik.standalone.MainKt`:
```
Main.kt
├─ LlmConfig.fromEnv() → LlmConfig(backend, openai, google, systemPrompt)
├─ llmConfig.createLlm() → LiteLlm (рефлексия для google)
├─ SqliteStores.open(dbPath) → ConversationStore + MessageStore + WorkingMemoryStore
├─ McpRegistry.fromConfig(...) → список NamedTool из всех MCP-серверов
├─ ChatAgent(stores, llm, llmConfig, tools)
└─ embeddedServer(CIO, port) { agentikAgent(agent, "/agentik") }
```
**`ChatAgent`** — реализация `Agent`:
- `live: Map<String, ChatConversation>` — in-memory кэш активных сессий.
- `createConversation(temp)`: для temp — только кэш; для persistent —
`upsert(conversation)` + `workingMemory.append(System(systemPrompt))`
атомарно, потом `live[id] = ChatConversation(...)`.
- `getConversation(id)`: кэш → store (cold-load). Переоткрытие восстанавливает
`LiteConversation` из `workingMemory.list(id)` (см. ниже).
- `send`/`events`/`interrupt`/`delete`/`rename` проксируются в `ChatConversation`.
**`ChatConversation`** — реализация `Conversation`:
- На каждый ход: `workingMemory.append(User)` → `liteConv.sendStreamContents(...)`
→ стрим `LiteDelta` → клиенту (AppendText / ToolCall / ToolResult).
- На завершение стрима: `messageStore.append(Assistant)` + `workingMemory.append(Assistant)`
+ `conversationStore.touch(id)`.
- Tool-loop: на `delta.toolCalls` → emit `Event.ToolCall` → execute
(`tool.invoke(argsJson)`) → emit `Event.ToolResult` →
`liteConv.addToolResult(callId, name, result)` → продолжение стрима.
- На ошибку хода (init/стрим LLM): `failTurn` → `messageStore.append(Error)`
(audit; в working_memory не пишется) + `Event.Error` + `Event.End`, живой
`LiteConversation` сбрасывается и пересобирается на следующем `send`.
- `LiteConversation` живёт **один на весь `ChatConversation`** для обоих
бэкендов: KV-cache движка сохраняется между ходами. Это контракт
litert-api, не только Google-specific.
- На `interrupt()` отменяется текущий `Job` и `liteConv.interrupt()`.
**`WorkingMemory`** — read-write контекст, который видит LLM:
- `System(text, sourceMessageId=null)` — системный промпт.
- `User/Assistant(sourceMessageId, content)` — снимки реальных сообщений.
- `compact(dropFromOrderIdx, conversationId)` — v1: DELETE rows ≥ order_idx,
summarization-вставка отложена (нужен дизайн-проработка).
**`MessageStore`** — append-only аудит. На каждый ход дописываются
`UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Error`. Никаких
update/delete кроме каскада из `ConversationStore.delete`.
## 5. Слои персистентности
`SqliteStores` (jvmMain) — три интерфейса из commonMain, одна
SQLite-БД. Схема (`jvmMain/sqldelight/`):
| таблица | поля | роль |
|---|---|---|
| `conversation` | id, title, is_temporal, created_at, updated_at | карточки диалогов |
| `message` | id, conversation_id, role, payload_json, created_at | аудит-хвост |
| `working_memory` | id, conversation_id, order_idx, source_message_id, payload_json, created_at | контекст LLM |
`Content` (text/image) и per-kind payload сериализуются в
`payload_json` через `kotlinx.serialization`. Maps в каскадное удаление
(`ConversationStore.delete` — одна транзакция).
## 6. Фасады
- **`:server` (HTTP+SSE)** — основной, KMP, Ktor Route extension.
`Route.agentikAgent(agent, path = "/agentik")` монтирует весь CRUD +
live event-stream. Подробнее — `docs/STANDALONE.md`.
- **A2A (`pw.binom.a2a:server`)** — заготовка в `libs.versions.toml`,
подключён в `:standalone` для совместимости с зависимостями через
`:client`, но **не монтируется в `Main.kt`**. Если/когда понадобится —
`Route.a2aAgent(...)` (как у agui в старом дизайне).
## 7. Конфигурация (env)
| env | назначение |
|---|---|
| `AGENTIK_PORT` | порт Ktor (default `8080`) |
| `AGENTIK_DB_PATH` | путь к SQLite (default `./agentik.db`) |
| `AGENTIK_SYSTEM_PROMPT` | текст системного промпта |
| `AGENTIK_LLM_BACKEND` | `openai` (default) или `google` |
| `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `OPENAI_MODEL` | для backend=openai |
| `AGENTIK_GOOGLE_MODEL_PATH` / `AGENTIK_GOOGLE_THREADS` | для backend=google |
| `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop |
## 8. Что осталось за рамками v1
- Суммаризация `WorkingMemory.compact()` (пункт `compact(dropFromOrderIdx)` пуст).
- Image input (Content.Image принимается, но `LiteContentPart.Image`
сейчас дропается в Chat-цикле с warn-логом — модель видит только текст).
- Auth на фасаде.
- A2A transport facade в `Main.kt`.
+330
View File
@@ -0,0 +1,330 @@
# Memory — дизайн (draft)
> Обсуждение долговременной памяти агента. Начат 2026-09-13.
> Цель — выбрать модель хранения и поиска **до** написания кода.
>
> **Update 2026-09-14:** пивот хранения. Поднимаем не один monolithic
> backend, а **абстракцию** (`MemoryStore`/`MemoryPrefetcher`/`MemoryReviewer`/
> `MemoryTools` в `:memory-api`) и подменяемые реализации. v1 идёт на
> Hermes-style **§-файлах** в `~/.agentik/memory/{USER,WORLD,PREFERENCES}.md`
> (модуль `:memory-md`). SQLite+vector sidecar из §3 ниже переезжает в
> следующий бэкенд (`:memory-vector`, фаза 2+) — обоснование там же,
> отличий в API не будет. §3.1 («почему не файлы») остаётся аргументом
> *против единого источника истины вне основной БД*, но под абстракцией
> это уже не та проблема: факты в MD живут отдельно, а всё остальное
> state диалогов по-прежнему в `agentik.db`.
## 1. Требования
Что память должна делать в agentik:
1. **Хранить факты** между сессиями: о пользователе (USER), о мире/проектах (WORLD), о предпочтениях (PREFERENCE).
2. **Быстро находить релевантное** перед каждым ходом (prefetch, top-K).
3. **Быть перезаписываемой** — пользователь и сам агент могут удалять/править/архивировать.
4. **Переживать рестарты** — данные не теряются.
5. **Работать на native таргетах** (`:server` уже KMP, `:standalone` linuxX64 в плане).
6. **Single-binary** — никаких внешних сервисов типа Qdrant.
Чего **не** обязательно в v1:
- миллионы заметок;
- мультипользовательские tenant'ы;
- sub-ms ANN на >100K векторов.
## 2. Варианты хранения
### A. Текстовые файлы (Hermes-style)
- `~/.hermes/memories/MEMORY.md` и `USER.md`, разделитель записей `§`.
- Поиск: substring / FTS5 / LLM-ранжирование.
- **+** человекочитаемо, легко бэкапить (`cp`), редактировать руками.
- **−** не семантический: «как мы деплоим» не найдёт «systemctl + k3s».
- **−** параллельно с SQLite (у нас всё остальное в `agentik.db`) — два места правды.
### B. SQLite + FTS5
- Всё в `agentik.db`: `memory_note` + `memory_note_fts` (виртуальная FTS5-таблица).
- Поиск: FTS5 BM25 + trigram (для русского).
- **+** один процесс, знакомый API, никаких новых зависимостей.
- **−** keyword-only: «k8s» не матчится с «kubernetes», «Go» не находится в «статически типизированном языке с горутинами».
### C. SQLite (canonical) + векторный sidecar
- Канонический store: `memory_note(id, category, content, created_at, last_used_at, use_count, conversation_id?, source, embedding_model, embedding_dim)` — обычные столбцы.
- Векторный индекс: либо BLOB-столбец с packed Float32Array + brute-force cosine, либо отдельная embedded-БД (sqlite-vss, LanceDB).
- **+** семантический поиск «по смыслу»; canonical store остаётся SQL-инспектируемым (можно `grep`, `sqlite3 agentik.db "SELECT …"`).
- **+** гибридный ranking: similarity × recency × use_count.
- **−** зависимость на embedding-модель.
### D. Чисто векторная БД (Qdrant / Milvus / Weaviate / Chroma)
- **+** production-grade ANN.
- **−** отдельный процесс, **ломает single-binary философию agentik**.
## 3. Рекомендация — гибрид **C**
**Канонический store — SQLite (как у нас всё остальное). Векторный индекс — sidecar.**
### 3.1 Почему не A (только файлы)
- В agentik **всё** состояние уже в SQLite: разговоры, working memory, summary, ошибки. Раздваивать на файлы = доп. синхронизация при каждом save/delete + новая категория бэкапов.
- Файлы не масштабируются на >100 заметок без индекса: `grep -F` это O(N) по байтам.
- Семантический поиск всё равно хочется — придётся добавлять векторы позже, тогда MD превращается в sidecar с теми же проблемами синхронизации, но без преимуществ.
### 3.2 Почему не B (только FTS5)
- Keyword-поиск быстро упирается в перефразирование: пользователь пишет «на чём билдим?», заметка говорит «building with Gradle 9.4.1» — без морфологии и/или эмбеддингов не находится.
- Мультиязычность (русские заметки, английские запросы) — FTS5 с trigram работает, но семантика всё равно точнее.
- Цена эмбеддингов на нашем масштабе ($0.004 на 1000 заметок единоразово + копейки на save) практически нулевая.
### 3.3 Почему не sqlite-vss / LanceDB embedded
- **sqlite-vss** — расширение SQLite, надо пересобирать под каждый native таргет (linuxX64, iosArm64, iosSimulatorArm64, mingwX64). Блокирует наш linuxX64-чек (NATIVE-COMPATIBILITY.md).
- **LanceDB Java SDK** — есть (Apache 2.0), но JVM-only (JNI к нативной `.so`); на ios/iosSimulator без отдельного билда не работает.
- На нашем масштабе (<10K заметок на агента) **brute-force cosine по BLOB-столбцу** — правильный порядок сложности:
- 1 эмбеддинг = 1536 floats × 4 байта ≈ 6 KB (OpenAI small) или 384 × 4 ≈ 1.5 KB (MiniLM).
- 10K × 6 KB = 60 MB в RAM. Дешево.
- Поиск: 10K cosine-similarity = ~0.5 ms на JVM, <0.1 ms нативно. Не нужен HNSW.
Если когда-нибудь выйдем за 50K заметок — переедем на sqlite-vss или LanceDB без поломки API: `MemoryStore.search(query, k)` остаётся прежним.
### 3.4 Схема канонического store (предложение)
```sql
CREATE TABLE memory_note (
id TEXT PRIMARY KEY, -- "mem-<uuid>"
category TEXT NOT NULL, -- 'user' | 'world' | 'preference'
content TEXT NOT NULL, -- полный текст заметки
conversation_id TEXT, -- NULL = global, иначе привязана к диалогу
source TEXT NOT NULL, -- 'agent_save' | 'user_explicit' | 'auto_review'
created_at INTEGER NOT NULL, -- epoch ms
last_used_at INTEGER NOT NULL, -- когда последний раз матчилась в prefetch
use_count INTEGER NOT NULL DEFAULT 0, -- сколько раз выдавалась в prefetch
embedding_model TEXT NOT NULL, -- 'openai/text-embedding-3-small' (для миграций)
embedding_dim INTEGER NOT NULL,
embedding BLOB NOT NULL -- packed Float32Array, little-endian
);
CREATE INDEX memory_note_category_idx ON memory_note(category);
CREATE INDEX memory_note_last_used_idx ON memory_note(last_used_at DESC);
CREATE INDEX memory_note_conversation_idx ON memory_note(conversation_id);
```
Размер: на 10K заметок ≈ 60 MB (OpenAI small) или 15 MB (MiniLM). Дешевле, чем `agentik.db`-кеш.
## 4. Embedding: где считать
| Вариант | + | − |
|---|---|---|
| **Удалённо через litellm** (`text-embedding-3-small`, 1536-dim, $0.02/1M tokens) | 0 локальных деплов, высокое качество, мультиязычный | +1 HTTP на save/query; нужен ключ OpenAI |
| **Локально через ONNX Runtime** (`all-MiniLM-L6-v2`, 384-dim, $0) | оффлайн, native-совместимо, без задержек | +25 MB к бинарю; хуже качество; инициализация ~500 ms |
| **Через GOOGLE backend** (Gemini embedding) | уже работающее API | привязывает embedding к backend; GOOGLE может не иметь OpenAI embedding-API |
| **Гибрид** (по умолчанию OpenAI, fallback на локальный если ключа нет) | resilience | +сложность |
**Рекомендация:** на v1 — **отдельный embedding-конфиг**, дефолт OpenAI через litellm, фоллбэк на локальный MiniLM через ONNX (если выйдет 0.3+ java SDK и мы соберём native-билды под все таргеты — отложим в v2).
### Стоимость на реальном использовании
- text-embedding-3-small, 1000 заметок × 200 токенов = 200K токенов = **$0.004** единоразово.
- `memory_save` ≈ $0.00001 (200 токенов на индексацию).
- `prefetch` (1 query embedding) ≈ $0.000003.
- 100 заметок/месяц, 50 ходов/день = <$0.01/месяц. Ничтожно.
## 5. Жизненный цикл заметки
```
review-loop (post-turn)
│
▼
memory_save(category, content) ──► write to memory_note
│ │
│ ├──► embedding = embed(content)
│ ├──► use_count = 0
│ └──► last_used_at = created_at
│
▼
prefetch(user_message, topK=10) ──► vector top-K + recency rerank
│ │
│ ▼
│ bump use_count, last_used_at
│ │
│ ▼
│ inject into user message prefix:
│ "[Контекст — то, что я помню]
│ - факт 1
│ - факт 2
│ [/Контекст]"
│
▼
(eventually)
│
manual memory_delete(id) ◄── пользовательская команда
│
curator: last_used_at < 90 days ago ──► status = 'archived' (не видна в prefetch)
```
`status` в схеме нет — архивация = `use_count == 0 AND last_used_at < archive_threshold`. Чисто по таймстампам, без LLM. Curator (Фаза 4) делает это в фоне раз в сутки.
## 6. Review-loop (как у Hermes, адаптированный)
Не fork-agent. **Один-shot LLM-вызов** на `LiteLlm.sendStreamContents` с урезанным туловым whitelist'ом (`memory_save`, `memory_search`, `memory_list`, `skill_save`). Тот же backend, что у основного агента.
Триггер: **каждые N ходов** (default 10, настраивается `AGENTIK_MEMORY_NUDGE_INTERVAL`).
Условие: только после **успешно** завершённого хода (не прерванного, не упавшего).
Промпт (рус/англ по языку разговора):
```
Ты — фоновый аналитик. Посмотри на последний разговор и реши, есть ли
что запомнить в долговременную память агента. Сохраняй только если:
1. Пользователь рассказал о себе: persona, привычки, предпочтения.
2. Пользователь рассказал о проекте/окружении: стек, инструменты, сроки.
3. Пользователь выразил ожидания к тому, как агент должен работать.
Если ничего нет — просто ответь "nothing to save" и не вызывай тулы.
Если есть — вызови memory_save(category, content) для каждого факта.
```
`category`: одна из `user` / `world` / `preference`. Агент решает сам.
## 7. Открытые вопросы (нужны решения)
| # | Вопрос | Предложение |
|---|---|---|
| 1 | **Переносимость embeddings между моделями.** Поменяем модель → переиндексировать всё? | Хранить `embedding_model` в строке; на старте проверять, что у всех строк он одинаковый; иначе фоновый reindex. Дёшево ($0.004 на 1000 заметок). |
| 2 | **Мультиязычность.** Русские заметки + английские запросы. | text-embedding-3-small мультиязычен (тестировал на рус+англ — ок); MiniLM — частично. На v1 OpenAI хватает. |
| 3 | **TTL заметок.** Hermes без TTL. У нас возможны устаревшие факты. | Curator (Фаза 4): `use_count == 0 AND last_used_at < 90d` → архив. Без жёсткого удаления. |
| 4 | **Персональные данные / секреты.** Можем сохранить API-ключ из разговора. | Guard rail в `memory_save`: regex-detect на токеноподобные паттерны (`sk-…`, `Bearer …`, JWT); агент не должен сохранять. Финальный review — пользователь. Шифрование at-rest — отдельная тема (см. общий security pass). |
| 5 | **Когда НЕ писать.** Review должен решить «не сохранять». | Встроено в промпт выше. Если агент сомневается — не пишет. |
| 6 | **Каталог и формат SKILL-аналога для памяти.** Hermes делает это как `USER.md` vs `MEMORY.md`. У нас одна таблица с `category`. Нужно ли разделение? | Одной таблицы достаточно (категория в строке). Преимущество: один запрос, одна транзакция. |
| 7 | **Привязка к conversation_id.** Глобальная память vs per-conversation. | Колонка nullable. По умолчанию глобальная (`NULL`). Привязка — только когда факт явно про «этот диалог» (например, «в этом диалоге используем минимальный API»). |
| 8 | **Prefetch size.** Сколько фактов впрыскивать в user message? | Default 10, настраивается `AGENTIK_MEMORY_PREFETCH_TOPK`. |
| 9 | **Где считать query embedding — клиент или «агент»?** | На стороне агента (там же, где `LiteLlm`). Один HTTP-вызов на turn. |
| 10 | **Что делать с дубликатами?** «Пользователь — DevOps» vs «Пользователь работает с k8s» — это две заметки или одна с тегами? | v1: две отдельные заметки. Curator (Фаза 4) объединяет похожие через LLM. |
## 8. Что фиксируем **до кода**
- [ ] **Канонический store**: SQLite-таблица `memory_note` в общей `agentik.db`.
- [ ] **Поиск**: семантический (embedding) + recency/use_count rerank.
- [ ] **Embedding backend**: `openai/text-embedding-3-small` через litellm; в v2 — локальный MiniLM через ONNX.
- [ ] **Vector index**: brute-force cosine по BLOB-столбцу (v1) → sqlite-vss / LanceDB (v2 если нужно).
- [ ] **Single-binary**: всё в нашем процессе, без внешних сервисов.
- [ ] **Prefetch**: топ-K (default 10) заметок в user-message-префиксе.
- [ ] **Review-loop**: один-shot LiteLlm-вызов с whitelist-тулсетом, каждые N ходов.
- [ ] **Curator** (отложен в Фазу 4): архивация по `use_count` + `last_used_at`.
## 9. Что НЕ делаем в v1
- /learn и автоматическое создание скиллов (отдельная фаза).
- Vector compression / quantization (нужно только при >100K заметок).
- Multi-agent shared memory (один пользователь — один агент — один набор заметок).
- Encryption at rest (общий security pass).
- Embedding-кэш для текстов, которые уже были заэмбеджены (можно LRU в RAM).
- Memory.conflict resolution (агент сохранил «k8s», потом «nomad» — это конфликт или эволюция? v1 не разрешает, v2 — curator).
## 10. Пофазный план
### Фаза 1 — Память (v2.1, ~600 строк кода + ~300 тестов)
**Цель:** агент запоминает между сессиями.
- `:memory` KMP-модуль: `MemoryNote`, `MemoryCategory` (`USER | WORLD | PREFERENCE`), `MemoryStore` (commonMain интерфейс) + SQLite jvm-impl.
- Тулы: `MemoryReadTool` (поиск), `MemorySaveTool`, `MemoryListTool`, `MemoryDeleteTool`.
- `MemoryPrefetcher.prefetch(query, topK): List<MemoryNote>` — embed query, brute-force cosine, rerank по recency × use_count.
- `EmbeddingClient` — обёртка над litellm `/v1/embeddings` (один HTTP-вызов, retry, кэш in-RAM LRU на 256 текстов).
- `ChatAgent.memory: MemoryStore` + `EmbeddingClient` параметры.
- `ChatConversation.send()`: перед каждым `sendStreamContents` инжектит top-K заметок в префикс user-сообщения.
- `ChatAgent.afterTurn()` hook: после успешного хода инкрементит `_turnsSinceReview`; если ≥ `memoryNudgeInterval` — запускает фоновую корутину `MemoryReviewAgent`.
- `MemoryReviewAgent`: один-shot `LiteLlm.sendStreamContents` с review-промптом + whitelist-тулсетом (только `memory_*` + `skill_save`). Результат — 0+ записей в `memory_note`.
- `SystemGuidance` константы: `MEMORY_GUIDANCE`, `SKILLS_GUIDANCE`, `TOOL_USE_ENFORCEMENT` (из Hermes `prompt_builder.py`).
- Конфиг: `AGENTIK_MEMORY_BACKEND` (openai/local/none), `AGENTIK_MEMORY_NUDGE_INTERVAL` (10), `AGENTIK_MEMORY_PREFETCH_TOPK` (10), `AGENTIK_EMBEDDING_MODEL` (text-embedding-3-small).
- **Тесты:** MemoryStore round-trip, prefetch ranking, review-loop пишет 0–N заметок на фейковом LLM, защита от секретов (regex), embedding-кэш работает, миграция схемы идемпотентна.
### Фаза 2 — Контекстная компрессия (v2.2, ~500 строк)
**Цель:** диалог переживает 50+ ходов без 400 от LLM.
- `TokenEstimator` — грубая оценка (chars / 4) по working memory.
- `Compressor.shouldCompress()` — триггер: `tokens > threshold_percent * contextLimit` (default 75%). Anti-thrashing: ≤5 неудачных попыток подряд → пауза 10 минут.
- `WorkingMemoryStore.compact(dropFromOrderIdx, summaryEntry: MessageRecord.Summary)` расширяется: drop + insert в одной транзакции. Уже сигнатура, нужно тело.
- `ChatConversation.afterTurn()` (после review-loop): если `shouldCompress` — `head + summary + tail`:
- head = system + первые 3 не-system записи;
- middle = всё что не head/tail; уходит в aux-вызов;
- tail = последние ~20K токенов (или 8 записей, что больше).
- Aux summarizer prompt — портированный из Hermes `context_compressor.py` (структура с Historical Task Snapshot / Goal / Active State / Blocked / Resolved / Remaining Work).
- Резюме пишется как `MessageRecord.Summary(text, createdAt)` в working memory, заменяет middle в `getOrCreateLiteConversation`.
- **Тесты:** каждая граничная ситуация (head+1, всё в tail, пустой middle, ошибка aux-LLM → static fallback).
### Фаза 3 — Skill self-improvement (v2.3, ~400 строк)
**Цель:** агент сам создаёт/обновляет скиллы.
- `SkillSaveTool(action=create|update, name, description, body)` (LiteTool).
- Расширить `SkillCatalog` в `:skills`: `lastUsedAt`, `useCount`, `archivedAt`. Сохранение в SQLite (`skill_usage` таблица) — нужно решить, отдельная БД или в `agentik.db`. Рекомендую в `agentik.db` (та же причина, что и для memory).
- `SkillLoader.loadDirectory` теперь читает timestamp + useCount из SQLite при наличии, иначе — из filesystem mtime.
- `MemoryReviewAgent` дополняется skill-частью (combined prompt) или запускается параллельно вторым вызовом.
- Тулы в whitelist review: `memory_*` + `skill_save` + `skill_delete`.
### Фаза 4 — Curator (v2.4, ~300 строк)
**Цель:** фоновая уборка устаревших заметок и скиллов.
- Корутина `CuratorJob` запускается в `Main.kt`, тик раз в сутки (настраивается).
- **Без LLM:** сканирует `memory_note` и `skills`. Если `last_used_at < 90d` и `use_count == 0` → `archivedAt = now()`. Не удалять.
- **С LLM (опц., выкл по умолчанию):** consolidation pass — fork-agent (один-shot) ищет похожие заметки, объединяет в umbrella-заметку, архивирует исходные. Для скиллов — то же самое.
- Через `:server` endpoint `GET /memory` / `GET /memory/archived` для пользовательского контроля.
### Фаза 5 — Умный prefetch (v2.5, отложено)
- Переход brute-force → sqlite-vss при >50K заметок.
- Локальный embedding через ONNX Runtime (`all-MiniLM-L6-v2`) как fallback при отсутствии OpenAI-ключа.
- Embedding-кэш на диске (LRU).
- Vector quantization (int8) для экономии RAM.
## 11. Что меняется в коде
### Новые модули
```
:memory (новый KMP модуль, commonMain + jvmMain)
commonMain/.../MemoryNote.kt -- sealed: id, category, content, ...
commonMain/.../MemoryCategory.kt -- USER | WORLD | PREFERENCE
commonMain/.../MemoryStore.kt -- интерфейс (insert, list, search, delete)
commonMain/.../EmbeddingClient.kt -- интерфейс (embed: String -> FloatArray)
commonTest/.../MemoryStoreTest.kt
jvmMain/.../sqlite/SqliteMemoryStore.kt -- INSERT/SELECT + brute-force cosine
jvmMain/.../embedding/LitertlmEmbedding.kt -- HTTP-вызов /v1/embeddings через ktor-client
jvmMain/.../embedding/InMemoryEmbedding.kt -- для тестов
jvmTest/.../SqliteMemoryStoreTest.kt
jvmTest/.../LitertlmEmbeddingTest.kt -- через WireMock или httptestserver
```
### Изменения в `:standalone`
```
agent/MemoryReadTool.kt / MemorySaveTool.kt / MemoryListTool.kt / MemoryDeleteTool.kt
agent/MemoryReviewAgent.kt -- один-shot review, fire-and-forget coroutine
agent/SkillSaveTool.kt -- в Фазе 3
llm/SystemGuidance.kt -- MEMORY_GUIDANCE / SKILLS_GUIDANCE константы
llm/CompressionPrompts.kt -- в Фазе 2
persistence/WorkingMemoryStore.kt -- расширение compact() в Фазе 2
agent/ChatAgent.kt -- +memory, +embedding, +afterTurn() hook, +review coroutine
agent/ChatConversation.kt -- +userMessagePrefix(memory snapshot), +afterTurn compress trigger (Фаза 2)
config/AgentikConfig.kt -- +memory config block
Main.kt -- +memory init, +curator coroutine (Фаза 4)
```
### Изменения в `:proto`
Не нужны. Память — внутренняя фича standalone, не часть протокола. (Если захотим управлять памятью через IRC/CLI — добавим `Memory` в `:proto`, как `Agent` в Фазе 4.)
### Изменения в `:server` / `:client`
Не нужны. Память не идёт через HTTP API в v1. (Только чтение дампа `GET /memory` — это Фаза 4.)
## 12. Чеклист перед кодом
- [ ] Подтвердить: канонический store в `agentik.db` (а не отдельный файл).
- [ ] Подтвердить: embedding через litellm `text-embedding-3-small` (а не локальный ONNX).
- [ ] Подтвердить: brute-force cosine в BLOB (а не sqlite-vss / LanceDB на старте).
- [ ] Подтвердить: review-loop как один-shot, не полноценный fork-agent.
- [ ] Подтвердить: prefetch инжектится в user-message-префикс (не system_instruction).
- [ ] Подтвердить: `MemoryNote.conversation_id` nullable, по умолчанию NULL (глобальная).
- [ ] Ответить на вопросы 1–10 из раздела 7.
После закрытия чеклиста — открываем Фазу 1.
+357
View File
@@ -0,0 +1,357 @@
# Standalone — рантайм агента agentik
`standalone` — это исполняемое JVM-приложение (точка входа `pw.binom.agentik.standalone.MainKt`), которое поднимает реальный агент `ChatAgent` (stateful, SQLite-персистентный) и навешивает на него HTTP+SSE фасад `:server`. LLM-движок выбирается через `AGENTIK_LLM_BACKEND` — на v1 поддерживаются `litert-openai` (любой OpenAI-совместимый endpoint) и `litert-google` (on-device движок LiteRT-LM 0.17.0 через нативную `.so`-библиотеку из litertlm-jvm 0.17.0).
Этот документ описывает, как `standalone` собран и как его расширять.
---
## 1. Что в коробке после `git clone`
```
:proto — единое ядро протокола (KMP, commonMain)
Agent / Conversation / Event / Message / Content / AgentEvent
stateful: агент сам хранит историю и working memory
:server — HTTP+SSE фасад :proto
public Route.agentikAgent(agent, path = "/agentik")
:skills — парсер и каталог навыков (KMP, commonMain + jvmMain-загрузчик)
SKILL.md (opencode frontmatter) / *.yaml, renderSystemPromptSection()
:standalone — JVM-рантайм с реальным LLM-агентом
ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google)
default 8080:
GET /health health check
POST /agentik/conversations создать диалог (201)
GET /agentik/conversations список
GET /agentik/conversations/{id} один диалог
PATCH /agentik/conversations/{id} переименовать
DELETE /agentik/conversations/{id} удалить (204)
POST /agentik/conversations/{id}/messages отправить user-сообщение (202)
POST /agentik/conversations/{id}/interrupt прервать текущий ход (202)
GET /agentik/conversations/{id}/messages страница истории
GET /agentik/conversations/{id}/events SSE live-события хода
GET /agentik/events SSE live-события агента
```
Полная таблица эндпоинтов — в `docs/ARCHITECTURE.md` (раздел «:server»).
---
## 2. Архитектура слоёв
```
клиенты транспорт
┌───────────────┐ ┌─────────────────────────────┐
│ Web / CLI / │ ──HTTP──► │ Route.agentikAgent(agent) │
│ desktop │ ──SSE───► │ :server (Ktor + Netty) │
│ │ └──────────────┬──────────────┘
└───────────────┘ │
▼
pw.binom.agentik.proto.Agent
(ChatAgent)
│
┌───────────────┴───────────────┐
▼ ▼
ChatConversation.send(content) agent.events / agent.getConversations
│
▼
┌──────────────────────────────────────┐
│ 1. audit: append UserMessage │
│ 2. working_memory: append User │
│ 3. ensureLiteConversation: │
│ first turn → create from WM; │
│ next turns → reuse (KV-cache) │
│ 4. sendStreamContents → emit │
│ StartResponse / AppendText / │
│ End │
│ 5. audit + WM: append AssistantMessage│
└──────────────────────────────────────┘
│ │
▼ ▼
Conversation.events(after) Conversation.getMessages(after)
(live, no replay) (история)
```
Слои рантайма:
```
:standalone
├── persistence/ ← интерфейсы и records (commonMain, без зависимостей)
│ ConversationStore / MessageStore / WorkingMemoryStore
│ ConversationRecord / MessageRecord / WorkingMemoryEntry / Content
│ Payload.kt — JSON-сериализация
├── persistence/sqlite/ ← JVM: SQLDelight-схема + три SQLite-реализации
│ SqliteStores.open(path | inMemory)
│ src/jvmMain/sqldelight/.../*.sq
├── llm/ ← LlmConfig (env → OpenAI/Google), backend-agnostic
└── agent/ ← ChatAgent + ChatConversation (stateful, long-lived LiteConv)
```
Ключевой инвариант: **разговор живёт внутри агента, а не в клиенте и не в транспорте.** Транспорт — лишь сериализатор: HTTP пишет в/читает из `:server`-эндпоинтов, SSE шлёт события. У них нет своего состояния диалога.
---
## 3. Точка входа: `pw.binom.agentik.standalone.MainKt`
```kotlin
fun main() {
val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080
val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db"
val llmConfig = LlmConfig.fromEnv()
val llm = llmConfig.createLlm()
val stores = SqliteStores.open(dbPath = dbPath)
val agent = ChatAgent(
id = "agentik",
stores = stores,
llm = llm,
llmConfig = llmConfig,
)
val server = embeddedServer(Netty, port = port) {
routing {
get("/health") { call.respondText("ok") }
agentikAgent(agent, path = "/agentik")
}
}
Runtime.getRuntime().addShutdownHook(Thread {
agent.close(); stores.close(); llm.close()
})
server.start(wait = true)
}
```
Один `ChatAgent` отвечает и за диалоги (`/agentik/conversations/...`), и за live-события (`/agentik/events`). Все три ресурса — БД, LLM, Netty — корректно закрываются в shutdown-хуке.
---
## 4. Контракт `Agent` (от `pw.binom.agentik.proto`)
Реализация **обязана** уметь:
| метод | смысл |
|---|---|
| `id: String` | идентификатор агента |
| `createConversation(temp: Boolean): Conversation` | новая сессия, `temp=true` — не персистить |
| `getConversation(id): Conversation?` | достать по id, `null` если нет |
| `deleteConversation(id): Boolean` | удалить |
| `getConversations(offset, limit)` | страница списка |
| `events(after: Instant): Flow<AgentEvent>` | live-события по множеству разговоров |
Реализация `Conversation`:
| метод | смысл |
|---|---|
| `id: String` | идентификатор диалога |
| `title: String?` | заголовок (может быть `null`) |
| `isTemporal: Boolean` | `true` = не персистить (`temp=true` при создании) |
| `isSupportImageInput/Output: Boolean` | мультимодальные возможности (для v1 оба `false`) |
| `updatedAt: Instant` | последний `send`/`rename` |
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
| `interrupt()` | остановить текущий ход (best-effort) |
| `events(after): Flow<Event>` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) |
| `getMessages(after, offset, limit)` | страница истории |
| `rename(title)` | переименовать |
| `close()` | освободить ресурсы |
Главное: `send` ничего не возвращает. Чтобы получить события, нужно **отдельно** подписаться на `events(after)` ДО `send` либо сразу после — поток событий стартует с момента подписки, бэкфилл через `getMessages`.
---
## 5. Persistence — dual-log
`ChatAgent` хранит каждую сессию в двух логически разных таблицах:
| таблица | назначение | мутации |
|---|---|---|
| `message` | append-only audit log. Все user/assistant/tool-call/tool-result/error сообщения. Никогда не редактируется (кроме каскадного `DELETE` при удалении диалога). | только `INSERT` |
| `working_memory` | mutable LLM-контекст. System-prompt + текущая история + (в v2) суммаризации. | `INSERT`, `compact(dropFromIdx, summary)` |
Маппинг `:proto.Message ↔ MessageRecord` живёт в `ChatConversation.kt` (`toProto`/`toStorage`) — сами `MessageRecord` намеренно НЕ зависят от `:proto`, чтобы можно было сменить транспорт без миграции таблиц.
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
**Ошибки хода персистятся.** Если ход провалился (LLM/движок недоступны — например, HTTP 400 от endpoint'а), `ChatConversation.failTurn` пишет терминальную запись `MessageRecord.Error` в audit и эмитит `Event.Error` + `Event.End`. Благодаря audit-записи ошибка видна не только подписчику live-SSE, но и клиенту, который делает backfill через `getMessages` (polling/переподключение): в истории будет `Message.Error(id, message, code?)`, а для этого user-сообщения не будет `AssistantMessage`. При ошибке стрима живой `LiteConversation` сбрасывается — следующий `send` пересоберёт его из `working_memory`. В working_memory `Error` не пишется (модель не должна видеть ошибки прошлых ходов).
### `MessageStore`
```kotlin
suspend fun append(record: MessageRecord)
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
suspend fun listAll(conversationId: String): List<MessageRecord>
```
### `WorkingMemoryStore`
```kotlin
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
suspend fun list(conversationId: String): List<WorkingMemoryRow>
suspend fun clear(conversationId: String)
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
```
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
### `ConversationStore`
```kotlin
suspend fun upsert(record: ConversationRecord)
suspend fun get(id: String): ConversationRecord?
suspend fun delete(id: String): Boolean // каскадно чистит message + working_memory
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
suspend fun rename(id: String, title: String?): Instant?
suspend fun touch(id: String, now: Instant)
```
Все три store — `AutoCloseable`; корневой ресурс `SqliteStores` закрывает их вместе с `SqlDriver`.
---
## 6. LLM-конфигурация (`LlmConfig`)
`LlmConfig.fromEnv()` парсит env, валидирует обязательные поля и выбирает бэкенд через `AGENTIK_LLM_BACKEND`:
- `openai` (default) — `litert-openai`, текст-онли чат против любого OpenAI-совместимого endpoint.
- `google` — `litert-google` (LiteRT-LM 0.17.0, on-device `.task`/`.litertlm` модель, требует нативной библиотеки через `litertlm-jvm`).
### Общие env
| env | смысл | default |
|---|---|---|
| `AGENTIK_PORT` | порт Netty (`/agentik`, `/health`) | `8080` |
| `AGENTIK_DB_PATH` | путь к SQLite-файлу | `./agentik.db` |
| `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` |
| `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» |
| `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) |
| `AGENTIK_SKILLS_DIR` | папка с навыками (рекурсивно; `SKILL.md` или `*.yaml`/`*.yml`) | не задано (навыков нет) |
### Backend `openai`
| env | смысл |
|---|---|
| `OPENAI_BASE_URL` | endpoint (например, `https://api.openai.com/v1` или `http://localhost:11434/v1`) — обязательно |
| `OPENAI_API_KEY` | ключ модели — обязательно |
| `OPENAI_MODEL` | имя модели (например, `gpt-4o-mini`, `myopenai/local/codding`) — обязательно |
### Backend `google` (on-device LiteRT-LM)
| env | смысл | default |
|---|---|---|
| `AGENTIK_GOOGLE_MODEL_PATH` | путь к `.task` или `.litertlm` модели — обязательно |
| `AGENTIK_GOOGLE_CACHE_DIR` | каталог кеша скомпилированных graph'ов | пусто (системный tmp) |
| `AGENTIK_GOOGLE_THREADS` | число CPU-потоков для движка | `4` |
`AGENTIK_DB_PATH=:memory:` создаёт in-memory БД (только для тестов и интеграционных проверок).
### Long-lived LiteConversation — ОБЯЗАТЕЛЬНО для обоих бэкендов
`LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (LiteRT-LM 0.17.0 умеет правильно восстанавливать state при `systemInstruction + пары User/Assistant` в `initialMessages`).
`LiteLlm.capabilities: LiteCapabilities?` (litert-api 7+) — `litert-google` читает из заголовка on-disk модели (text/vision/audio, supportsThinking, supportsFunctionCalling, maxVisionTokenBudget); `litert-openai` возвращает `null` (модель не хранится на диске). Используй для фильтрации модальностей в клиенте.
---
## 7. Расширение
### Подключить тулы (MCP / in-agent)
```kotlin
// 1) In-agent tool (нативный LiteTool):
val echoTool = object : LiteTool {
override fun describe(): String =
"""{"type":"function","function":{"name":"echo","description":"Echo a string","parameters":{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}}}"""
override fun invoke(args: String): String = "echoed: $args"
}
val tools = listOf(NamedTool("echo", echoTool))
// 2) MCP (stdio / streamable HTTP) — конфиг в Claude Desktop-формате:
val mcp = McpConfig.fromEnv() // читает AGENTIK_MCP_CONFIG=path/to/mcp.json
val registry = McpRegistry.fromConfig(mcp) // стартует все серверы, лист LiteTool'ов
val tools = registry.namedTools // server__tool префикс автоматически
// 3) В обоих случаях:
val agent = ChatAgent(id, stores, llm, llmConfig, tools = tools)
```
`LiteConversation` принимает `tools = ...` в `LiteConversationConfig`. На каждый `delta.toolCalls` из модели `ChatConversation.runTurn`:
1. Эмитит `Event.ToolCall(callId, toolName, argsJson)` клиенту (по SSE)
2. Записывает `MessageRecord.ToolCall` в audit + working memory (если не temp)
3. Вызывает `tool.invoke(argsJson)`
4. Эмитит `Event.ToolResult(resultId, resultText)`
5. Записывает `MessageRecord.ToolResult` (с `toolCallId = callId`)
6. Кормит `liteConv.addToolResult(callId, name, result)` в LiteConversation (KV-cache выживает между итерациями)
7. Цикл повторяется до `delta.toolCalls.isEmpty()`
ID у `ToolCall` и `ToolResult` разные (`tc-…` / `tr-…`), но `MessageRecord.ToolResult.toolCallId` указывает на `MessageRecord.ToolCall.id` той же логической пары. Этим достигается уникальность PK в таблице `message`.
### Навыки (skills)
Навыки — это «лениво загружаемые» инструкции: в системный промпт попадают только **имя + краткое описание**, а полный текст модель достаёт сама, вызывая встроенный инструмент `read_skill`.
**Формат файла** (два варианта, оба читаются):
1. opencode-style `SKILL.md` — YAML-frontmatter + markdown-тело:
```markdown
---
name: backend:spring:db-base
description: MUST load before any database work.
---
# ... полный текст навыка ...
```
2. Голый YAML `*.yaml` / `*.yml` — поля `name`, `description`, опционально `body`:
```yaml
name: lint
description: Run the linter before committing.
body: |
# Lint
Run `./gradlew detekt`.
```
Загрузка: `SkillLoader.loadDirectory(dir)` рекурсивно обходит `AGENTIK_SKILLS_DIR`, парсит файлы и возвращает `SkillCatalog` + список ошибок (битый файл не валит загрузку, дубликат имени — ошибка, выигрывает первый по пути).
**Что попадает в системный промпт** (`SkillCatalog.renderSystemPromptSection()`): блок `## Навыки` со списком `- **name**: description`. Тело навыка в промпт НЕ попадает.
**Инструмент `read_skill`** (`SkillReadTool`) регистрируется в `ChatAgent` автоматически, если каталог непустой; для модели он выглядит как обычная функция с аргументом `{"name": "<skill>"}`. Модель вызывает его по необходимости, результат возвращается как обычный tool-result (см. tool-loop ниже).
```kotlin
val skills = SkillLoader.loadDirectory(File(System.getenv("AGENTIK_SKILLS_DIR"))).catalog
val agent = ChatAgent(id, stores, llm, llmConfig, tools = mcpRegistry.namedTools, skills = skills)
```
Навык можно передать и напрямую «в тулзах» — `SkillReadTool` достаточно обернуть в `NamedTool(SkillReadTool.NAME, SkillReadTool(catalog))`, но при непустом `skills`-параметре это делается за вас.
### Добавить ещё один транспорт
Каждый транспорт — отдельный модуль, который получает `Agent` и сериализует его под свой протокол:
* `:server` (HTTP+SSE) — готов, `Route.agentikAgent(agent, path = "/agentik")`
* `:client` (HTTP-клиент) — готов, `AgentikAgent(id, baseUrl, httpClient)`
* `:irc-server` — IRC-фасад, в планах
Транспорт **не имеет доступа к внутренностям `ChatAgent`** — он видит только интерфейс `Agent`. Это и есть «транспортно-агностичное ядро».
### Добавить ещё один LLM-бэкенд
1. Описать `Config` data class с нужными полями.
2. Реализовать `LiteLlm`/`LiteConversation` поверх движка (см. litert-kmp — там уже есть `litert-google`, `litert-openai`, `litert-koog`).
3. Расширить `LlmConfig.createLlm()` веткой `when`.
### Заменить SQLite на Postgres / MongoDB / etc
1. Реализовать три store-интерфейса поверх нового движка.
2. Передать их в `ChatAgent` вместо `SqliteStores`.
3. Удалить (или оставить за `:standalone`-флагом) `:persistence/sqlite/`.
---
## 8. Что НЕ делает `standalone` сегодня
* **Нет суммаризации.** `WorkingMemoryStore.compact` уже есть, но без LLM-вызова для генерации текста суммаризации.
* **Нет авторизации.** Все эндпоинты открыты.
* **Нет инкрементальной догрузки старых сообщений.** `getMessages(after)` работает с offset/limit, но без «схлопывания» (compaction в визуальной истории — задача клиента).
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.
+304
View File
@@ -0,0 +1,304 @@
# Agentik: Toolsets + Storage Refactor — Implementation Plan
**Status:** LOCKED. Implementation proceeds autonomously, no mid-implementation
pings.
**Date:** 2026-09-15.
## Resolved questions (defaults applied)
- **Q1** (tool-result language): **English** — for consistency with the toolset
section in the system prompt (also English).
- **Q2** (which info in `Toolset 'X' activated.` text): **Q2-α minimal** —
`"Toolset 'X' activated."`, no tool names. Tool descriptions are already in the
next request's `tools[]` array, no duplication needed.
No further open questions.
---
## Architectural decisions (locked)
| Parameter | Decision |
|---|---|
| Layers | `:agent-core` (existing `:standalone` core), `:agent-toolsets` (new wrapper), `:storage-core` / `:storage-inmemory` / `:storage-sqlite` / `:storage-android` (new). |
| Default toolsets | `listOf()`. When empty, zero agent changes: no `enable_toolset` / `disable_toolset` tools, no toolset section in prompt. Full invisibility. |
| Activation scope | per-conversation (`conv.id`). |
| Dispatch outcome | `Run(value: String) \| Error(message: String)`. `Substituted` variant dropped. |
| Auto-activation | Stays in `ToolsetDispatchPolicy` only — never mentioned in the system prompt. Falls through silently when the model "goofed". |
| `enable_toolset` / `disable_toolset` audit | H2: written as ordinary `ToolCall` / `ToolResult` rows in SQLite (model sees them in its own history on subsequent turns). |
| `ToolsetContribution` fields | `name: String` + `description: String` + `tools: List<NamedTool>`. No `enabledByDefault`. |
| Tool naming | `${toolsetName}_${verb}`; prefix = `name`. |
| Catalog rendering | Both ACTIVE and INACTIVE rows render as `name — description`, identical format. |
| `Toolset` section language | English. |
| Tool-result language | English (per Q1). |
| Activation timeout | 10 minutes since last use; lazy cleanup on every `activeNames(convId)` call. No background timers. |
| Persistence | In-memory only. Not persisted. New conversation = fresh registry. |
| Storage | `StorageBundle = MessageStore + WorkingMemoryStore + ReflectionStore + SkillStore`. SQLite is one impl, others possible. |
| Skills vs toolsets | Separate concepts. No link between skill index and toolset catalog. |
| Module split | A5-γ: extract interfaces, defer actual Android impl. |
---
## Module layout (final)
```
agentik/
├── storage-core/ NEW (KMP)
│ ├── MessageStore / WorkingMemoryStore / ReflectionStore / SkillStore interfaces
│ └── StorageBundle aggregator
│
├── storage-inmemory/ NEW (KMP, tests)
│ ├── InMemoryMessageStore
│ ├── InMemoryWorkingMemoryStore
│ ├── InMemoryReflectionStore
│ ├── InMemorySkillStore
│ └── InMemoryStorageBundle
│
├── storage-sqlite/ NEW (JVM, refactor of existing)
│ ├── Sqldelight-backed impls of all four stores
│ └── SqliteStorageBundle
│
├── storage-android/ NEW (Android, deferred — placeholder
│ └── (placeholder file; full impl comes with Android module)
│
├── agent-toolsets/ NEW (KMP)
│ ├── ToolsetContribution (name + description + tools)
│ ├── ToolsetRegistry
│ ├── ToolsetDispatchPolicy (wraps inner DispatchPolicy)
│ ├── SystemPromptToolsetSection (SystemPromptContributor)
│ ├── EnableToolsetTool / DisableToolsetTool (NamedTool)
│ └── ToolsetWrapper — not exposed as a separate class; the registry + policy +
│ section are constructed and injected individually.
│
├── standalone/ MODIFIED
│ ├── Main.kt — wires new modules (StorageBundle + ToolsetRegistry)
│ ├── build.gradle.kts — new dependencies
│ ├── ChatAgent / ChatConversation — depends on StorageBundle (interface), not
│ │ SqliteStores directly. accept ToolsetRegistry + contributions via ctor.
│ └── all existing tests still green.
```
Out of scope (kept in `:standalone` for now): Main, transport adapters (`/agentik`,
`/agui`, `/a2a`), LiteLlm backend wiring, debug endpoints, MCP integration.
---
## Commit sequence (six commits, each builds + tests green)
### Commit 1 — `:storage-core` interfaces + bundle
**New module** `storage-core` (KMP, commonMain only):
- `MessageStore.kt` — interface (append, getMessages, tokenStats).
- `WorkingMemoryStore.kt` — interface (append, list, compact, archive).
- `ReflectionStore.kt` — interface (insert, listRecent, count, listForConversation, deleteOlderThan).
- `SkillStore.kt` — interface (catalog, upsert, remove, exists, all).
- `StorageBundle.kt` — `data class StorageBundle(val messageStore, workingMemoryStore, reflectionStore, skillStore)`.
- One umbrella test: `StorageInterfaceContractTest` asserting parameter naming is right (compile-time only).
**Gradle setup**:
- `settings.gradle.kts` — `include(":storage-core")`.
- `storage-core/build.gradle.kts` — KMP `commonMain` only with `api kotlinx-coroutines-core`,
`api kotlinx-datetime`. No JVM target yet.
**Verification**:
- `./gradlew :storage-core:build` — green.
- `./gradlew :storage-core:jvmTest` — green (placeholder test).
**No `:standalone` modifications yet.**
### Commit 2 — `:storage-inmemory` impl
**New module** `storage-inmemory` (KMP, commonMain):
- All four `InMemory*` implementations backed by `ConcurrentHashMap` + `MutableStateFlow`-ish
snapshots for `getMessages(..): Flow<MessageRecord>`.
- `InMemoryStorageBundle` factory.
**Tests** (KMP commonTest):
- `InMemoryMessageStoreTest` — append + getMessages (paged flow).
- `InMemoryWorkingMemoryStoreTest` — append + list + compact + archive.
- `InMemoryReflectionStoreTest` — insert + listRecent + count.
- `InMemorySkillStoreTest` — catalog + upsert + remove.
**Gradle setup**:
- Depends on `:storage-core`.
**Verification**:
- `./gradlew :storage-inmemory:allTests` — green.
### Commit 3 — `:storage-sqlite` refactor
**New module** `storage-sqlite` (JVM):
- Move existing `SqliteStores` (and related Sqldelight code) here.
- Split into `SqliteMessageStore`, `SqliteWorkingMemoryStore`, `SqliteReflectionStore`,
`SqliteSkillStore`.
- `SqliteStorageBundle(val db: AgentikDatabase)`. Existing schema/migrations are
unchanged. Reads from existing `.sq` files.
**Migration**:
- Existing tests that depend on `SqliteStores` continue to work — keep a thin
compat: `SqliteStores` becomes a deprecated alias:
```kotlin
@Deprecated("Use SqliteStorageBundle")
class SqliteStores(db: AgentikDatabase): StorageBundle by SqliteStorageBundle(db)
```
**Tests** (JVM):
- Existing sqldelight-backed tests still green.
- Add contract tests for new individual stores.
**Verification**:
- `./gradlew :storage-sqlite:jvmTest` — green.
- All existing `:standalone` tests that referenced `SqliteStores` still compile
(via the @Deprecated alias).
### Commit 4 — `:agent-toolsets` core
**New module** `agent-toolsets` (KMP, commonMain):
- `ToolsetContribution.kt` — data class.
- `ToolsetRegistry.kt` — `class ToolsetRegistry(clock: Clock = Clock.System)`.
- `activeNames(convId): Set<String>` — lazy cleanup.
- `enable(convId, name): String` — 4-case response table (see A2 above).
- `disable(convId, name): String` — 4-case response table (see A3 above).
- `DEFAULT_TIMEOUT_MS = 10 * 60 * 1000`.
- `ToolsetDispatchPolicy.kt` — `interface DispatchPolicy { dispatch(call, sessionId): DispatchOutcome }`,
`class ToolsetDispatchPolicy(inner: DispatchPolicy, registry, contributions, coreTools)`:
- Dispatch loop:
```
while (true):
if name in resolved (core + active sets) tools: return inner.dispatch(...)
if name has prefix matching known toolset T: registry.enable(sessionId, T); continue
return Error("tool 'X' not found")
```
- `SystemPromptToolsetSection.kt` — `class SystemPromptToolsetSection(contributions, enabledSets)`
implementing `:agent-core:SystemPromptContributor` (defined here for now;
could later live in `:agent-core`).
- `EnableToolsetTool.kt` / `DisableToolsetTool.kt` — `NamedTool` implementations.
Args schema: `{ "name": "<string>" }` as JSON.
- `ToolsetSystemMessages.kt` — companion with `coreToolsDescription: List<String>`
(just `["enable_toolset", "disable_toolset"]`).
**Tests** (commonTest):
- `ToolsetRegistryTest`:
- enable + activeNames immediately reflects.
- enable idempotent (returns "already active").
- disable on inactive returns "deactivated" (per A3 case 2).
- timeout cleanup via injected `Clock`.
- per-conversation isolation (different convIds).
- `ToolsetDispatchPolicyTest` — synthetic `ping` toolset:
- model calls `ping({})` without enable → auto-enabled, executed, returns "pong".
- model calls `enable_toolset({})` with empty args → error message.
- model calls `ping({})` with no such toolset registered → error.
### Commit 5 — `:agent-toolsets` integration (SystemPromptContributor)
Same module, adds:
- Define `SystemPromptContributor` interface inside `:agent-toolsets` (or move to
`:agent-core`, but `:agent-toolsets` already has it; keep here for now).
- `SystemPromptToolsetSection` renders:
```
## Toolsets
Named groups of tools. One set per conversation. Use enable_toolset({"name": X})
to add a toolset; disable_toolset({"name": X}) to remove. Both are idempotent.
ACTIVE
name — description
INACTIVE call enable_toolset({"name": X}) to add
name — description
...
```
- `:standalone/Main.kt` — when `contributions.isNotEmpty()`:
- Build `ToolsetRegistry()`.
- Add `EnableToolsetTool(registry)` and `DisableToolsetTool(registry)` to
`ChatAgent.tools`.
- Inject `SystemPromptToolsetSection` into the system-prompt contributors chain.
- Wrap the `DispatchPolicy` with `ToolsetDispatchPolicy(...)`.
**Tests**:
- `SystemPromptToolsetSectionTest` — renders both ACTIVE and INACTIVE rows identically.
- Default (:standalone config) has `contributions = listOf()` → no tools, no
prompt section.
### Commit 6 — `:standalone` swap StorageBundle
**Changes to `:standalone`**:
- `Main.kt` — build `SqliteStorageBundle(db)` instead of `SqliteStores(db)`.
- `ChatAgent` constructor: `(..., stores: StorageBundle, ...)` (was:
`SqliteStores`).
- All references to `SqliteStores.messageStore` → `stores.messageStore` (etc.).
- `build.gradle.kts` — add `implementation(project(":storage-sqlite"))`,
remove direct reliance on sqlite plumbing internals if any.
**No behaviour change**: existing tests still pass.
**Verification**:
- `./gradlew :standalone:jvmTest` — all green (was 264 tests pre-refactor).
- Build `:standalone:fatjar` — works.
- Smoke test against `/tmp/agentik-sandbox` — e2e green (model still answers,
memory still works, no regressions).
---
## Verification at every commit
After each commit:
1. `./gradlew :<module>:build` — green.
2. Affected module's tests — green.
3. `./gradlew :standalone:jvmTest` — green (no regressions in the biggest test
suite). Once `:standalone` starts depending on the new modules in commit 5/6,
this becomes the canonical regression check.
4. After commit 6 — run the e2e smoke probe against the sandbox (curl `/agentik`
`/agui` `/a2a` health + one turn).
---
## Risks and mitigations
- **Risk**: existing `SqliteStores` references scatter across `:standalone`.
**Mitigation**: keep `@Deprecated` alias until all references are swept (commit
6); final sweep at commit 7 (deferred).
- **Risk**: `ChatAgent` ctor signature changes break many call sites.
**Mitigation**: introduce `StorageBundle` as a thin ctor param; existing ctors
that default to `SqliteStorageBundle(db)` still work.
- **Risk**: `DispatchPolicy` is currently implicit (direct call to
`toolsByName`). Wrapping it from outside may break test doubles.
**Mitigation**: introduce `DispatchPolicy` interface in commit 4 alongside the
wrapper. Existing fakes gain the one-method interface trivially.
- **Risk**: toolset section length in prompt — for many toolsets, ~5 lines × N
contributions.
**Mitigation**: `description` field is short (<100 tokens); limit contributions
count via agent config.
---
## Open items for future (NOT in this implementation)
- Android `:storage-android` impl (A5-γ defers this).
- Wrapper-class abstraction over (registry + policy + section) once Android
needs it.
- Test-time Clock injection beyond `ToolsetRegistryTest`.
- Pre-validation of `description` text via LLM (probably not worth it).
- Exporting `ToolsetDispatchPolicy` to consumers outside `:standalone`.
---
## How to resume after context loss
If this session is compacted and the plan lost:
1. Read this file: `agentik/docs/TOOLSETS-PLAN.md`.
2. Verify current commit: `git log --oneline -6` — should show commits in the
order above.
3. Resume from the next commit in the sequence not yet landed.
If commits 1-3 are landed but no further, jump to commit 4.
If commits 1-5 are landed, jump to commit 6.
+12
View File
@@ -0,0 +1,12 @@
# Default version for local builds only (когда CI/CD не передал -Pversion=<tag>).
# Имя ключа специально НЕ 'version' — иначе Gradle-мерж gradle.properties и
# -Pversion= возьмёт default из gradle.properties. Передавай через CICD:
# ./gradlew ... -Pversion=$(git describe --tags)
# см. .gitea/workflows/release.yml (использует -Pversion=$GITHUB_REF_NAME).
agentik.version.default=0.1.0-SNAPSHOT
# KMP jvm target uses JDK 21 for both compilation and toolchain.
org.gradle.jvmargs=-Xmx4096M -XX:+UseG1GC
# Android SDK путь для будущей Android-сборки (пока не используется).
# sdk.dir=/home/subochev/Android/Sdk
+100
View File
@@ -0,0 +1,100 @@
[versions]
kotlin = "2.4.20"
kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.8.0"
ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0"
litert = "8"
sqldelight = "2.3.2"
shadow = "8.3.5"
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" }
[libraries]
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
a2a-shared = { module = "pw.binom.a2a:shared", version.ref = "a2a" }
a2a-client = { module = "pw.binom.a2a:client", version.ref = "a2a" }
a2a-server = { module = "pw.binom.a2a:server", version.ref = "a2a" }
# --- YAML для парсинга скилов (opencode-style frontmatter). KMP. ---
kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" }
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" }
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
# --- Ktor (сервер) ---
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
# --- 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 ---
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
# --- kMMIO (dev.karmakrafts.kmmio) — резерв под будущую vector-DB, пока не используется ---
# kmmio-core = { module = "dev.karmakrafts.kmmio:kmmio-core", version = "2.3.1" }
# --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. ---
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
# --- Логирование: kotlin-logging (тонкая обёртка над slf4j-api) + logback-classic (binding). ---
kotlin-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "kotlin-logging" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+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`.
## Текущий статус
Используется продакшеном. Контракт стабильный.
+29
View File
@@ -0,0 +1,29 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
// Чистый KMP commonMain — модели и интерфейсы памяти, без платформенного IO.
// Зеркалит набор :proto / :server. Конкретные бэкенды (MD, SQLite+vector)
// живут в отдельных модулях и могут таргетить только нужное подмножество.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
@@ -0,0 +1,20 @@
package pw.binom.agentik.memory
/**
* Категория факта в долговременной памяти.
*
* - [USER] — о пользователе (кто он, чем занимается, привычки).
* - [WORLD] — о мире/проектах (стек, инструменты, люди, окружение).
* - [PREFERENCE] — как пользователь хочет, чтобы агент работал.
*/
enum class MemoryCategory(val id: String) {
USER("user"),
WORLD("world"),
PREFERENCE("preference");
companion object {
fun fromId(id: String): MemoryCategory =
entries.firstOrNull { it.id == id }
?: throw IllegalArgumentException("unknown memory category: $id")
}
}
@@ -0,0 +1,27 @@
package pw.binom.agentik.memory
import kotlin.time.Instant
/**
* Одна запись в долговременной памяти агента.
*
* @property id уникальный идентификатор (`mem-<uuid>` по умолчанию).
* @property category категория факта.
* @property content полный текст заметки (одно-два предложения на практике).
* @property createdAt время создания.
* @property lastUsedAt когда последний раз заметка выдавалась в prefetch.
* @property useCount сколько раз выдавалась в prefetch (для ранжирования).
* @property conversationId если не null — заметка привязана к конкретному диалогу;
* null — глобальная (дефолт).
* @property source как попала в память.
*/
data class MemoryNote(
val id: String,
val category: MemoryCategory,
val content: String,
val createdAt: Instant,
val lastUsedAt: Instant,
val useCount: Int = 0,
val conversationId: String? = null,
val source: MemorySource,
)
@@ -0,0 +1,20 @@
package pw.binom.agentik.memory
/**
* Recall: достать релевантные факты для следующего хода (например — последнее
* сообщение пользователя). Результат инжектится в user-message-префикс
* контекстным блоком перед отправкой в LLM.
*
* Бэкенды могут реализовать как keyword-search (MD), так и семантический
* поиск по эмбеддингам (vector-store).
*
* Метод обязан вызывать [MemoryStore.markUsed] для каждой выданной заметки
* (если хочет корректный учёт recency/useCount).
*/
interface MemoryPrefetcher {
suspend fun prefetch(
query: String,
topK: Int = 10,
category: MemoryCategory? = null,
): List<MemoryNote>
}
@@ -0,0 +1,81 @@
package pw.binom.agentik.memory
/**
* Пара (пользователь, ассистент) для review-loop'а.
*
* @property conversationId id диалога, из которого взят ход. Нужен, чтобы
* потом привязать появившиеся заметки к диалогу
* (глобальные заметки идут с conversationId=null).
*/
data class ReviewedTurn(
val userMessage: String,
val assistantMessage: String,
val conversationId: String? = null,
)
/**
* Пара (user + assistant) с временной меткой для пакетного review-loop'а.
* Используется при compaction'е working memory — когда ходы уходят в summary,
* у нас последний шанс вытащить из них факты и положить в долговременную память.
*/
data class ConversationTurn(
val userMessage: String,
val assistantMessage: String,
val createdAt: kotlin.time.Instant? = null,
)
/**
* Кандидат на новую заметку, предложенный review-loop'ом. У `id` нет —
* бэкенд назначает при upsert.
*/
data class NewMemoryNote(
val category: MemoryCategory,
val content: String,
)
/**
* Обновление существующей заметки (например, исправление формулировки).
*/
data class MemoryUpdate(
val id: String,
val newContent: String? = null,
)
/**
* Вердикт review-loop'а по одному ходу: что сохранить, что обновить, что удалить.
*/
data class MemoryReviewDecision(
val toSave: List<NewMemoryNote> = emptyList(),
val toUpdate: List<MemoryUpdate> = emptyList(),
val toDelete: List<String> = emptyList(),
)
/**
* Анализирует завершённый ход и возвращает вердикт — что должно попасть в
* долговременную память (или наоборот — удалиться).
*
* Реализации:
* - `:memory-md` — простая эвристика по ключевым словам (user/preference markers).
* - `:standalone` (позже) — один-shot LLM-вызов с whitelist-тулсетом.
*/
interface MemoryReviewer {
/** Review одного завершённого хода (вызывается после каждого assistant-ответа). */
suspend fun review(turn: ReviewedTurn): MemoryReviewDecision
/**
* Review пачки ходов перед compaction'ом working memory. Зовётся агентом
* за один раз перед удалением старых ходов — последний шанс вытащить из них
* факты до того, как они схлопнутся в summary.
*
* Дефолтная реализация — наивная: скармливает каждый ход в [review] по
* отдельности. Реализации с настоящей LLM-семантикой могут посмотреть на
* ходы пакетом и принимать решения с учётом контекста (например, не дублировать
* уже сохранённые факты).
*/
suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
val aggregated = MemoryReviewDecision(
toSave = turns.flatMap { review(ReviewedTurn(userMessage = it.userMessage, assistantMessage = it.assistantMessage)).toSave },
)
return aggregated
}
}
@@ -0,0 +1,28 @@
package pw.binom.agentik.memory
/**
* Запрос на семантический (или, в MD-бэкенде — ключевой) поиск по памяти.
*
* @property query текст запроса (обычно — последнее сообщение пользователя).
* @property topK максимум возвращаемых результатов.
* @property category фильтр по категории или null для всех.
* @property conversationId фильтр по диалогу: null = глобальная память,
* конкретный id = только факты этого диалога,
* особое значение [""] НЕ поддерживается — нужен явный диалог
* или null.
*/
data class MemorySearchQuery(
val query: String,
val topK: Int = 10,
val category: MemoryCategory? = null,
val conversationId: String? = null,
)
/**
* Результат поиска с оценкой релевантности. Шкала `score` бэкенд-специфична
* (для MD — overlap/total; для векторного — косинусная близость). Семантика — больше = лучше.
*/
data class MemorySearchResult(
val note: MemoryNote,
val score: Float,
)
@@ -0,0 +1,20 @@
package pw.binom.agentik.memory
/**
* Канал, через который заметка попала в память.
*
* - [AGENT_SAVE] — агент сам решил сохранить факт (явный вызов `memory_save` тулом).
* - [USER_EXPLICIT] — пользователь попросил сохранить факт.
* - [AUTO_REVIEW] — фоновый review-loop после хода (см. `MemoryReviewer`).
*/
enum class MemorySource(val id: String) {
AGENT_SAVE("agent_save"),
USER_EXPLICIT("user_explicit"),
AUTO_REVIEW("auto_review");
companion object {
fun fromId(id: String): MemorySource =
entries.firstOrNull { it.id == id }
?: throw IllegalArgumentException("unknown memory source: $id")
}
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.memory
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/**
* Событие мутации памяти для подписчиков (используется review-loop'ом и UI).
*/
sealed interface MemoryStoreEvent {
data class Upserted(val note: MemoryNote) : MemoryStoreEvent
data class Deleted(val id: String) : MemoryStoreEvent
}
/**
* Бэкенд-независимое хранилище долговременной памяти агента.
*
* Контракт:
* - [upsert] заменяет запись по `id` либо добавляет новую.
* - [get] / [list] / [search] — синхронные по id, листаются с пагинацией, поиск скорируется бэкендом.
* - [delete] удаляет по id; возвращает true если запись была.
* - [markUsed] бампит `lastUsedAt` и `useCount` — вызывается на каждом выдавании в prefetch.
* - [close] идемпотентен; после него любые методы бросают.
* - [events] опциональный стрим мутаций; бэкенды без поддержки возвращают [emptyFlow].
*
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных вызовов.
*/
interface MemoryStore : AutoCloseable {
suspend fun upsert(note: MemoryNote)
suspend fun get(id: String): MemoryNote?
suspend fun list(
category: MemoryCategory? = null,
conversationId: String? = null,
limit: Int = 100,
offset: Int = 0,
): List<MemoryNote>
suspend fun search(query: MemorySearchQuery): List<MemorySearchResult>
suspend fun delete(id: String): Boolean
suspend fun markUsed(id: String, at: Instant = Clock.System.now())
/**
* Архивирует заметки, которые:
* - не использовались дольше [maxAge] (считая от `now`);
* - имеют `useCount &lt;= [maxUseCount]` (по умолчанию 0, т.е. только никогда
* не выданные в prefetch).
*
* Семантика архивации зависит от бэкенда:
* - `:memory-md` — переименовывает -файл с суффиксом `.archived.{ts}`;
* - `:memory-vector` — удаляет из SQLite и JVector (там данные
* пересоздаются из `agentik.db` при старте).
*
* Default-имплементация использует [list] + [delete]; бэкенды могут
* переопределить для более чистой семантики (особенно MD).
*
* @return количество архивированных заметок.
*/
suspend fun archiveStale(
maxAge: kotlin.time.Duration,
maxUseCount: Int = 0,
now: Instant = Clock.System.now(),
): Int {
val all = list(limit = Int.MAX_VALUE)
val cutoff = now - maxAge
var archived = 0
for (n in all) {
if (n.lastUsedAt < cutoff && n.useCount <= maxUseCount) {
if (delete(n.id)) archived++
}
}
return archived
}
fun events(): Flow<MemoryStoreEvent> = emptyFlow()
override fun close()
}
@@ -0,0 +1,12 @@
package pw.binom.agentik.memory
/**
* Бандл компонентов памяти (store + prefetcher + reviewer), общий интерфейс
* для всех бэкендов (`:memory-md`, `:memory-vector`, ...). Используется в
* `:standalone` для единообразного DI.
*/
interface MemorySystem : AutoCloseable {
val store: MemoryStore
val prefetcher: MemoryPrefetcher
val reviewer: MemoryReviewer
}
@@ -0,0 +1,37 @@
package pw.binom.agentik.memory
/**
* Готовые блоки system-guidance, которые `:standalone` подмешивает в
* system-prompt разговора и в review-промпт. Тексты согласованы с
* `docs/MEMORY-DESIGN.md` (§6, §10) и описаниями [DefaultMemoryTools].
*/
object MemorySystemGuidance {
/** Блок для основного system-prompt разговора. Объясняет агенту, что у него есть память. */
const val MEMORY_GUIDANCE: String = """
У тебя есть долговременная память. Доступны тулы:
- memory_save(category, content) — сохранить факт, который пригодится в будущем.
- memory_read(query, top_k?) — поиск по памяти, когда нужен контекст.
- memory_list(category?, limit?) — список фактов (например, для показа пользователю).
- memory_delete(id) — удалить факт, когда пользователь просит забыть.
Категории:
- user: о пользователе (кто он, чем занимается, привычки).
- world: о проектах, стеке, окружении, людях.
- preference: как пользователь хочет, чтобы ты работал.
НЕ сохраняй: секреты (API-ключи, токены, пароли), одноразовые факты,
догадки без подтверждения. Сомневаешься — не сохраняй.
"""
/** Промпт для review-loop'а (см. `MemoryReviewer`). */
const val REVIEW_GUIDANCE: String = """
Ты — фоновый аналитик. Посмотри на последний разговор и реши, есть ли
что запомнить в долговременную память агента. Сохраняй только если:
1. Пользователь рассказал о себе: persona, привычки, предпочтения.
2. Пользователь рассказал о проекте/окружении: стек, инструменты, сроки.
3. Пользователь выразил ожидания к тому, как агент должен работать.
Если ничего нет — просто ответь "nothing to save" и не вызывай тулы.
Если есть — вызови memory_save(category, content) для каждого факта.
"""
}
@@ -0,0 +1,105 @@
package pw.binom.agentik.memory
/**
* Описание одного параметра инструмента памяти. Платформо-агностично —
* :standalone оборачивает это в `LiteTool` или backend-специфичные сущности.
*/
data class MemoryToolParam(
val name: String,
val type: String,
val description: String,
val required: Boolean = true,
val enumValues: List<String>? = null,
)
/**
* Описание инструмента, который видит LLM/агент. Имя/описание/параметры —
* то, что попадёт в system-prompt или tool-call schema.
*/
data class MemoryToolDescriptor(
val name: String,
val description: String,
val params: List<MemoryToolParam> = emptyList(),
)
/**
* Набор инструментов, которые память предоставляет агенту. Конкретный движок
* (LiteRT-LM, A2A, IRC) оборачивает эти дескрипторы в свои tool-классы.
*/
interface MemoryTools {
val save: MemoryToolDescriptor
val read: MemoryToolDescriptor
val list: MemoryToolDescriptor
val delete: MemoryToolDescriptor
companion object {
fun defaults(): MemoryTools = DefaultMemoryTools
}
}
/**
* Дефолтные описания инструментов. Язык — русский, чтобы согласовываться с
* [MemorySystemGuidance.MEMORY_GUIDANCE].
*/
object DefaultMemoryTools : MemoryTools {
override val save: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_save",
description = "Сохранить факт в долговременную память агента. Категория — одна из " +
"'user' (о пользователе: persona, привычки, предпочтения), " +
"'world' (о проектах, стеке, окружении, инструментах), " +
"'preference' (как пользователь хочет, чтобы ты работал). " +
"НЕ сохраняй секреты (API-ключи, токены, пароли) — pattern-detect и отказывай.",
params = listOf(
MemoryToolParam(
name = "category",
type = "string",
description = "Категория факта.",
required = true,
enumValues = listOf("user", "world", "preference"),
),
MemoryToolParam(
name = "content",
type = "string",
description = "Полный текст факта одним-двумя предложениями.",
required = true,
),
),
)
override val read: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_read",
description = "Поиск по долговременной памяти. Возвращает до top_k заметок, " +
"упорядоченных по релевантности (наибольшая первой). " +
"Используй перед ответами, требующими контекста о пользователе/проекте.",
params = listOf(
MemoryToolParam("query", "string", "Поисковый запрос (подстрока или ключевые слова).", true),
MemoryToolParam("top_k", "number", "Максимум заметок в ответе (default 10).", false),
MemoryToolParam(
"category", "string", "Фильтр по категории.", false,
enumValues = listOf("user", "world", "preference"),
),
),
)
override val list: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_list",
description = "Показать все (или отфильтрованные) заметки памяти. " +
"Используй, когда пользователь хочет проверить, что агент помнит.",
params = listOf(
MemoryToolParam(
"category", "string", "Фильтр по категории.", false,
enumValues = listOf("user", "world", "preference"),
),
MemoryToolParam("limit", "number", "Сколько заметок вернуть (default 100).", false),
),
)
override val delete: MemoryToolDescriptor = MemoryToolDescriptor(
name = "memory_delete",
description = "Удалить факт из памяти по id. Используй, когда пользователь явно " +
"просит забыть что-то.",
params = listOf(
MemoryToolParam("id", "string", "id заметки (формат mem-<uuid>).", true),
),
)
}
+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`.
## Текущий статус
Используется продакшеном. Подходит для долговременного "дневникового"
хранения.
+31
View File
@@ -0,0 +1,31 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
// Зеркалит набор :proto/:server, чтобы бэкенд памяти собирался на всех
// таргетах. Файловый IO идёт через kotlinx-io (SystemFileSystem).
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(project(":memory-api"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.io.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
}
}
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryStore
/**
* Prefetcher поверх [MdMemoryStore]. Делает keyword-поиск (см. [MdMemoryFormat.keywordScore])
* и бампит `lastUsedAt`/`useCount` у выданных заметок через [MemoryStore.markUsed].
*
* Вектор-бэкенд (будущая `:memory-vector`) поставит сюда эмбеддинг-семантику
* с тем же контрактом.
*/
class KeywordMdPrefetcher(private val store: MemoryStore) : MemoryPrefetcher {
override suspend fun prefetch(
query: String,
topK: Int,
category: MemoryCategory?,
): List<MemoryNote> {
if (query.isBlank()) return emptyList()
val results = store.search(
pw.binom.agentik.memory.MemorySearchQuery(
query = query,
topK = topK,
category = category,
),
)
val notes = results.map { it.note }
for (n in notes) store.markUsed(n.id)
return notes
}
}
@@ -0,0 +1,88 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn
/**
* Простая эвристика для review-loop'а: режет user/assistant-текст на предложения
* и помечает те, что содержат явные user/preference-маркеры (рус/англ).
*
* Это намеренно тупее LLM-реализации, которая появится в `:standalone` —
* без неё всё равно можно прогонять review-loop и набивать базовую память.
* Когда LLM-реализация подключится, она станет дефолтной, а эта останется
* для тестов и offline-сценариев.
*/
class KeywordMdReviewer(
private val maxFactsPerTurn: Int = 5,
private val maxFactsTotal: Int = 20,
) : MemoryReviewer {
private val userMarkers = listOf(
"я ", "я.", "я,", "мой ", "моя ", "моё ", "мои ", "мне ", "у меня ",
"i ", "i'm", "i am", "my ", "mine",
)
private val preferenceMarkers = listOf(
"я обычно", "я люблю", "я предпочитаю", "я не люблю", "мне нравится", "мне не нравится",
"i usually", "i prefer", "i like", "i don't like", "i hate",
)
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision {
// Эвристика берёт только user-message: ассистентские фразы вида
// "I can help with anything" ложно матчат "i " маркер, а настоящие
// предпочтения пользователя живут в его сообщениях. LLM-реализация
// (в :standalone) смотрит на обе стороны и решает тоньше.
val text = turn.userMessage.trim()
if (text.isBlank()) return MemoryReviewDecision()
return MemoryReviewDecision(toSave = extractFacts(text))
}
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
// Пакетный review: идём по ходам, вытаскиваем факты только из user-сообщений
// (assistant-фразы редко несут устойчивые факты о пользователе/мире).
// Дубликаты отсеиваются глобальным seen-Set'ом, лимит — maxFactsTotal,
// чтобы compaction не превращался в свалку.
val seen = HashSet<String>()
val toSave = ArrayList<NewMemoryNote>()
for (turn in turns) {
if (toSave.size >= maxFactsTotal) break
val text = turn.userMessage.trim()
if (text.isBlank()) continue
for (fact in extractFacts(text)) {
if (toSave.size >= maxFactsTotal) break
val key = fact.content.lowercase()
if (!seen.add(key)) continue
toSave.add(fact)
}
}
return MemoryReviewDecision(toSave = toSave)
}
private fun extractFacts(text: String): List<NewMemoryNote> {
val sentences = text.splitToSentences()
val result = ArrayList<NewMemoryNote>()
val seen = HashSet<String>()
for (s in sentences) {
if (result.size >= maxFactsPerTurn) break
val trimmed = s.trim()
if (trimmed.length < 6) continue
val lc = trimmed.lowercase()
val category = when {
preferenceMarkers.any { lc.contains(it) } -> MemoryCategory.PREFERENCE
userMarkers.any { lc.startsWith(it) || lc.contains(" $it") } -> MemoryCategory.USER
else -> null
} ?: continue
val dedupeKey = trimmed.lowercase()
if (!seen.add(dedupeKey)) continue
result.add(NewMemoryNote(category, trimmed))
}
return result
}
private fun String.splitToSentences(): List<String> =
split(Regex("(?<=[.!?\\n])\\s+")).filter { it.isNotBlank() }
}
@@ -0,0 +1,156 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import kotlin.time.Instant
/**
* Чистый парсер/сериализатор формата §-файлов памяти.
*
* Формат одного файла (например USER.md):
* ```
* § id=mem-xxx created=2026-09-14T10:00:00Z last_used=2026-09-14T10:00:00Z uses=0 source=agent_save
* Текст факта.
* Может занимать несколько строк.
* § id=mem-yyy created=...
*
* Другой факт.
* ```
*
* Разделитель записей — строка, начинающаяся с `§ ` (section-symbol + пробел).
* Это позволяет использовать `§` внутри контента, если он не стоит в начале строки
* с пробелом после него. Парсер смотрит именно на `§<пробел>` в начале строки.
*
* Запись заканчивается за один пустой строкой перед следующим `§`-заголовком.
*/
object MdMemoryFormat {
private const val SECTION_PREFIX = "§ "
/**
* Распарсить содержимое файла в список заметок. Неупорядоченно — порядок
* в файле не гарантирован, сортировка ложится на [MdMemoryStore].
*/
fun parse(category: MemoryCategory, body: String): List<MemoryNote> {
val lines = body.lines()
val out = mutableListOf<MemoryNote>()
var idx = 0
while (idx < lines.size) {
val line = lines[idx]
if (!line.startsWith(SECTION_PREFIX)) {
idx++
continue
}
val headerLine = line.removePrefix(SECTION_PREFIX).trim()
idx++
// Следующая пустая строка после заголовка — пропускаем.
if (idx < lines.size && lines[idx].isBlank()) idx++
// Контент — до следующего `§`-заголовка или EOF.
val contentLines = mutableListOf<String>()
while (idx < lines.size && !lines[idx].startsWith(SECTION_PREFIX)) {
contentLines.add(lines[idx])
idx++
}
val note = parseNote(category, headerLine, contentLines.joinToString("\n").trim())
if (note != null) out.add(note)
}
return out
}
/**
* Сериализовать список заметок в содержимое файла. Записи идут в порядке
* передачи; между ними — пустая строка. В конце всегда перевод строки.
*/
fun serialize(notes: List<MemoryNote>): String = buildString {
for ((i, note) in notes.withIndex()) {
if (i > 0) append('\n')
append(SECTION_PREFIX)
append("id=").append(note.id)
append(" created=").append(note.createdAt.toString())
append(" last_used=").append(note.lastUsedAt.toString())
append(" uses=").append(note.useCount)
append(" source=").append(note.source.id)
if (note.conversationId != null) {
append(" conv=").append(note.conversationId)
}
append('\n').append('\n')
append(note.content)
append('\n')
}
}
private fun parseNote(
category: MemoryCategory,
header: String,
content: String,
): MemoryNote? {
// Header: "id=<id> created=<iso> last_used=<iso> uses=<n> source=<id> [conv=<id>]"
var id: String? = null
var created: Instant? = null
var lastUsed: Instant? = null
var uses: Int? = null
var source: MemorySource? = null
var conv: String? = null
for (part in header.split(' ')) {
if (part.isEmpty()) continue
val eq = part.indexOf('=')
if (eq <= 0) continue
val key = part.substring(0, eq)
val value = part.substring(eq + 1)
try {
when (key) {
"id" -> id = value
"created" -> created = Instant.parse(value)
"last_used" -> lastUsed = Instant.parse(value)
"uses" -> uses = value.toInt()
"source" -> source = MemorySource.fromId(value)
"conv" -> conv = value
}
} catch (e: Throwable) {
return null
}
}
if (id == null || created == null || lastUsed == null || uses == null || source == null) {
return null
}
return MemoryNote(
id = id,
category = category,
content = content,
createdAt = created,
lastUsedAt = lastUsed,
useCount = uses,
conversationId = conv,
source = source,
)
}
/**
* Быстрый keyword-поиск по списку заметок. Используется внутри [MdMemoryStore]
* и [KeywordMdPrefetcher]. Возвращает результаты, отсортированные по score ↓.
*
* Алгоритм для v1: case-insensitive substring-match. Score = (число совпавших слов
* из запроса в заметке) / (общее число слов в запросе). Для пустого запроса
* отдаём все заметки, отсортированные по `lastUsedAt` ↓ (recency-фоллбэк).
*/
fun keywordScore(query: String, note: MemoryNote): Float {
val q = query.lowercase()
if (q.isBlank()) return 0f
val needle = q.splitToWords()
if (needle.isEmpty()) return 0f
val haystack = note.content.lowercase()
var hits = 0
for (w in needle) {
if (w.length >= 2 && w in haystack) hits++
}
return hits.toFloat() / needle.size.toFloat()
}
}
private fun String.splitToWords(): List<String> =
split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() }
@@ -0,0 +1,209 @@
package pw.binom.agentik.memory.md
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.io.IOException
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
import kotlinx.io.writeString
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Hermes-style persistent memory store, backed by `kotlinx-io`.
*
* Каждая [MemoryCategory] живёт в отдельном файле под [root]:
* `USER.md`, `WORLD.md`, `PREFERENCES.md`. Записи разделены `§` и парсятся
* в [MemoryNote] при первом обращении к файлу. Все мутации идут под [mu],
* атомарно через `.tmp` + `atomicMove` ([SystemFileSystem.atomicMove]).
*
* Файлы инициализируются лениво — `~/.agentik/memory/{category}.md` создаётся
* при первом [upsert]/[get]/[list], не при [openMdMemory].
*/
class MdMemoryStore internal constructor(
private val root: Path,
) : MemoryStore {
private val mu = Mutex()
private val cache: MutableMap<MemoryCategory, MutableList<MemoryNote>> = HashMap()
private val dirty: MutableSet<MemoryCategory> = HashSet()
private val events = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 64)
init {
try {
SystemFileSystem.createDirectories(root, mustCreate = false)
} catch (e: IOException) {
throw IllegalStateException("Cannot create memory root: $root", e)
}
}
fun observe(): SharedFlow<MemoryStoreEvent> = events.asSharedFlow()
private fun file(c: MemoryCategory): Path = Path(root, categoryFileName(c))
private fun ensureLoaded(c: MemoryCategory): MutableList<MemoryNote> {
cache[c]?.let { return it }
val path = file(c)
val notes: MutableList<MemoryNote> = if (SystemFileSystem.exists(path)) {
val text = SystemFileSystem.source(path).buffered().use { it.readString() }
MdMemoryFormat.parse(c, text).toMutableList()
} else {
mutableListOf()
}
cache[c] = notes
return notes
}
private suspend fun persist(c: MemoryCategory) {
val notes = cache[c] ?: return
val path = file(c)
val tmp = Path(path.toString() + ".tmp")
SystemFileSystem.sink(tmp).buffered().use { it.writeString(MdMemoryFormat.serialize(notes)) }
try {
SystemFileSystem.atomicMove(tmp, path)
} catch (e: Throwable) {
runCatching { SystemFileSystem.delete(tmp, mustExist = false) }
throw e
}
dirty.remove(c)
}
override suspend fun upsert(note: MemoryNote) {
val stored: MemoryNote
mu.withLock {
val list = ensureLoaded(note.category)
val idx = list.indexOfFirst { it.id == note.id }
stored = if (note.useCount == 0 && note.lastUsedAt == note.createdAt) {
note.copy(lastUsedAt = note.createdAt)
} else {
note
}
if (idx >= 0) list[idx] = stored else list.add(stored)
dirty.add(note.category)
persist(note.category)
}
events.tryEmit(MemoryStoreEvent.Upserted(stored))
}
override suspend fun get(id: String): MemoryNote? = mu.withLock {
for (c in MemoryCategory.entries) {
val list = ensureLoaded(c)
val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) return@withLock list[idx]
}
null
}
override suspend fun list(
category: MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = mu.withLock {
val cats: List<MemoryCategory> =
category?.let { listOf(it) } ?: MemoryCategory.entries.toList()
val all = ArrayList<MemoryNote>(64)
for (c in cats) {
for (n in ensureLoaded(c)) {
if (conversationId == null) {
if (n.conversationId == null) all.add(n)
} else {
if (n.conversationId == conversationId) all.add(n)
}
}
}
all.sortByDescending { it.createdAt }
val from = offset.coerceAtLeast(0)
if (from >= all.size) return@withLock emptyList()
val to = (from + limit).coerceAtMost(all.size)
all.subList(from, to).toList()
}
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> = mu.withLock {
if (query.query.isBlank()) return@withLock emptyList()
val cats: List<MemoryCategory> =
query.category?.let { listOf(it) } ?: MemoryCategory.entries.toList()
val out = ArrayList<MemorySearchResult>()
for (c in cats) {
for (n in ensureLoaded(c)) {
if (query.conversationId != null &&
n.conversationId != null && n.conversationId != query.conversationId
) continue
val score = MdMemoryFormat.keywordScore(query.query, n)
if (score > 0f) out.add(MemorySearchResult(n, score))
}
}
out.sortByDescending { it.score }
if (query.topK > 0 && out.size > query.topK) {
out.subList(query.topK, out.size).clear()
}
out
}
override suspend fun delete(id: String): Boolean = mu.withLock {
for (c in MemoryCategory.entries) {
val list = ensureLoaded(c)
val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) {
list.removeAt(idx)
dirty.add(c)
persist(c)
events.tryEmit(MemoryStoreEvent.Deleted(id))
return@withLock true
}
}
false
}
override suspend fun markUsed(id: String, at: Instant) {
mu.withLock {
for (c in MemoryCategory.entries) {
val list = ensureLoaded(c)
val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) {
val updated = list[idx].copy(lastUsedAt = at, useCount = list[idx].useCount + 1)
list[idx] = updated
dirty.add(c)
persist(c)
return@withLock
}
}
}
}
/** Сбрасывает все буферизованные записи на диск. Идемпотентно. */
suspend fun flush() = mu.withLock {
for (c in dirty.toList()) persist(c)
}
override fun close() {
runCatching {
kotlinx.coroutines.runBlocking { flush() }
}
}
}
/** Имя файла для категории: `user.md` / `world.md` / `preference.md`. */
internal fun categoryFileName(c: MemoryCategory): String = when (c) {
MemoryCategory.USER -> "user.md"
MemoryCategory.WORLD -> "world.md"
MemoryCategory.PREFERENCE -> "preference.md"
}
/**
* Открывает [MdMemoryStore] в указанной корневой директории. Директория
* создаётся (рекурсивно), если её ещё нет.
*/
fun openMdMemory(root: Path): MdMemoryStore = MdMemoryStore(root)
@@ -0,0 +1,34 @@
package pw.binom.agentik.memory.md
import kotlinx.io.files.Path
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystem
/**
* Связка store + prefetcher + reviewer на одной физической базе.
* Сейчас всё держится на одном [MdMemoryStore] — keyword-префетчер и
* эвристический ревьюер смотрят в него же.
*/
class MdMemorySystem internal constructor(
override val store: MemoryStore,
override val prefetcher: MemoryPrefetcher,
override val reviewer: MemoryReviewer,
) : MemorySystem {
override fun close() = store.close()
}
/**
* Собирает [MdMemorySystem] для указанной корневой директории.
* Store и prefetcher смотрят в одну базу; reviewer — keyword-эвристика
* (LLM-импл добавится в `:standalone`).
*/
fun openMdMemorySystem(root: Path): MdMemorySystem {
val store = openMdMemory(root)
return MdMemorySystem(
store = store,
prefetcher = KeywordMdPrefetcher(store),
reviewer = KeywordMdReviewer(),
)
}
@@ -0,0 +1,68 @@
package pw.binom.agentik.memory.md
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.files.SystemTemporaryDirectory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
class KeywordMdPrefetcherTest {
private val idCounter = atomicCounter()
private fun newRoot(): Path {
val name = "agentik-mem-${uniqueId()}"
val root = Path(SystemTemporaryDirectory.toString(), name)
SystemFileSystem.createDirectories(root, mustCreate = true)
return root
}
private fun note(id: String, category: MemoryCategory, content: String) = MemoryNote(
id = id, category = category, content = content,
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
@Test
fun prefetchReturnsRelevantAndBumpsUseCount() = runBlocking {
val root = newRoot()
openMdMemorySystem(root).use { sys ->
sys.store.upsert(note("u", MemoryCategory.USER, "User uses gradle 9.4.1"))
sys.store.upsert(note("w", MemoryCategory.WORLD, "Project runs on k3s"))
sys.store.upsert(note("p", MemoryCategory.PREFERENCE, "Prefers dark theme"))
val hits = sys.prefetcher.prefetch("gradle", topK = 5)
assertEquals(1, hits.size)
assertEquals("u", hits[0].id)
val after = sys.store.get("u")
assertTrue(after!!.useCount >= 1)
}
}
@Test
fun emptyQueryReturnsNothing() = runBlocking {
val root = newRoot()
openMdMemorySystem(root).use { sys ->
sys.store.upsert(note("u", MemoryCategory.USER, "anything"))
assertTrue(sys.prefetcher.prefetch("").isEmpty())
assertTrue(sys.prefetcher.prefetch(" ").isEmpty())
}
}
@Test
fun prefetchRespectsCategory() = runBlocking {
val root = newRoot()
openMdMemorySystem(root).use { sys ->
sys.store.upsert(note("u", MemoryCategory.USER, "k8s tip"))
sys.store.upsert(note("w", MemoryCategory.WORLD, "k8s is great"))
val userOnly = sys.prefetcher.prefetch("k8s", topK = 5, category = MemoryCategory.USER)
assertEquals(listOf("u"), userOnly.map { it.id })
}
}
}
@@ -0,0 +1,124 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.ReviewedTurn
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.runBlocking
class KeywordMdReviewerTest {
private val reviewer = KeywordMdReviewer()
@Test
fun detectsUserPreference() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "Я обычно предпочитаю vim, а не emacs.",
assistantMessage = "Хорошо, запомнил.",
),
)
assertTrue(decision.toSave.isNotEmpty(), "should suggest at least one note")
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && (it.content.contains("vim") || it.content.contains("предпочитаю")) })
}
@Test
fun ignoresNonPersonalStatements() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "Hello!",
assistantMessage = "Hi, I can help with anything.",
),
)
assertTrue(decision.toSave.isEmpty())
}
@Test
fun handlesEnglishPreference() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "I usually prefer dark mode in my IDE.",
assistantMessage = "Got it.",
),
)
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE })
}
@Test
fun deduplicatesExactMatch() = runBlocking {
val decision = reviewer.review(
ReviewedTurn(
userMessage = "I usually prefer tab over spaces.\nI usually prefer tab over spaces.",
assistantMessage = "Ok.",
),
)
val prefs = decision.toSave.filter { it.category == MemoryCategory.PREFERENCE }
assertEquals(1, prefs.size, "duplicate sentences should collapse")
}
@Test
fun preCompactionExtractsAcrossTurns() = runBlocking {
val decision = reviewer.reviewPreCompaction(
listOf(
ConversationTurn(
userMessage = "Я работаю на проекте agentik.",
assistantMessage = "Понял.",
),
ConversationTurn(
userMessage = "Я обычно использую kotlin для бэкенда.",
assistantMessage = "Хорошо.",
),
ConversationTurn(
userMessage = "Мне нравится архитектура memory-first.",
assistantMessage = "Согласен.",
),
),
)
// 3 user-фразы с маркерами — должно дать 3 факта.
assertEquals(3, decision.toSave.size, "should extract one fact per user phrase")
assertTrue(decision.toSave.any { it.category == MemoryCategory.USER && it.content.contains("agentik") })
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") })
assertTrue(decision.toSave.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("memory-first") })
}
@Test
fun preCompactionDedupesAcrossTurns() = runBlocking {
val decision = reviewer.reviewPreCompaction(
listOf(
ConversationTurn(
userMessage = "Я обычно предпочитаю vim.",
assistantMessage = "A.",
),
ConversationTurn(
userMessage = "Я обычно предпочитаю vim.",
assistantMessage = "B.",
),
),
)
val prefs = decision.toSave.filter { it.category == MemoryCategory.PREFERENCE }
assertEquals(1, prefs.size, "duplicate facts across turns should collapse")
}
@Test
fun preCompactionRespectsTotalLimit() = runBlocking {
val reviewer = KeywordMdReviewer(maxFactsTotal = 2)
val decision = reviewer.reviewPreCompaction(
(1..5).map {
ConversationTurn(
userMessage = "Я работаю над задачей #$it.",
assistantMessage = "ok",
)
},
)
assertEquals(2, decision.toSave.size, "should respect maxFactsTotal across turns")
}
@Test
fun preCompactionHandlesEmptyList() = runBlocking {
val decision = reviewer.reviewPreCompaction(emptyList())
assertTrue(decision.toSave.isEmpty())
}
}
@@ -0,0 +1,57 @@
package pw.binom.agentik.memory.md
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
class MdMemoryFormatTest {
@Test
fun parsesAndSerializes() {
val n = MemoryNote(
id = "mem-1",
category = MemoryCategory.USER,
content = "Hello, world!",
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
val body = MdMemoryFormat.serialize(listOf(n))
val parsed = MdMemoryFormat.parse(MemoryCategory.USER, body)
assertEquals(1, parsed.size)
assertEquals(n, parsed[0])
}
@Test
fun preservesMultiLineContent() {
val n = MemoryNote(
id = "mem-multi",
category = MemoryCategory.WORLD,
content = "Line1\nLine2\nLine3",
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
val body = MdMemoryFormat.serialize(listOf(n))
val parsed = MdMemoryFormat.parse(MemoryCategory.WORLD, body)
assertEquals(n.content, parsed[0].content)
}
@Test
fun keywordScore() {
val n = MemoryNote(
id = "k", category = MemoryCategory.WORLD,
content = "k8s kubectl kustomize",
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
source = MemorySource.AGENT_SAVE,
)
assertTrue(MdMemoryFormat.keywordScore("k8s", n) > 0f)
assertEquals(0f, MdMemoryFormat.keywordScore("python", n))
assertEquals(0f, MdMemoryFormat.keywordScore("", n))
}
}
@@ -0,0 +1,146 @@
package pw.binom.agentik.memory.md
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.files.SystemTemporaryDirectory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
class MdMemoryStoreTest {
private val idCounter = atomicCounter()
private fun newRoot(): Path {
val name = "agentik-mem-${uniqueId()}"
val root = Path(SystemTemporaryDirectory.toString(), name)
SystemFileSystem.createDirectories(root, mustCreate = true)
return root
}
private fun note(
id: String = "mem-${idCounter.next()}",
category: MemoryCategory = MemoryCategory.USER,
content: String,
createdAt: Instant = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt: Instant = createdAt,
useCount: Int = 0,
conversationId: String? = null,
source: MemorySource = MemorySource.AGENT_SAVE,
) = MemoryNote(
id = id, category = category, content = content,
createdAt = createdAt, lastUsedAt = lastUsedAt, useCount = useCount,
conversationId = conversationId, source = source,
)
@Test
fun roundTripSingleEntry() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
val n = note(
id = "mem-test-1",
category = MemoryCategory.USER,
content = "User prefers dark mode.",
conversationId = null,
)
store.upsert(n)
assertEquals(n, store.get("mem-test-1"))
}
}
@Test
fun persistsAcrossReopen() = runBlocking {
val root = newRoot()
val n1 = note(id = "mem-a", category = MemoryCategory.USER, content = "alpha")
val n2 = note(id = "mem-b", category = MemoryCategory.WORLD, content = "beta")
val n3 = note(id = "mem-c", category = MemoryCategory.PREFERENCE, content = "gamma")
openMdMemory(root).use { store ->
store.upsert(n1)
store.upsert(n2)
store.upsert(n3)
}
openMdMemory(root).use { store ->
assertEquals(n1, store.get("mem-a"))
assertEquals(n2, store.get("mem-b"))
assertEquals(n3, store.get("mem-c"))
val all = store.list()
assertEquals(3, all.size)
}
}
@Test
fun upsertReplacesById() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "mem-1", content = "first"))
store.upsert(note(id = "mem-1", content = "second"))
assertEquals("second", store.get("mem-1")?.content)
}
}
@Test
fun deleteRemovesById() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "mem-x", category = MemoryCategory.WORLD, content = "go"))
assertEquals(true, store.delete("mem-x"))
assertNull(store.get("mem-x"))
assertEquals(false, store.delete("mem-x"))
}
}
@Test
fun searchFiltersByCategoryAndScoresSubstring() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "u1", category = MemoryCategory.USER, content = "k8s cluster is using kubeadm"))
store.upsert(note(id = "u2", category = MemoryCategory.USER, content = "loves cats and code"))
store.upsert(note(id = "w1", category = MemoryCategory.WORLD, content = "project runs on k8s"))
store.upsert(note(id = "p1", category = MemoryCategory.PREFERENCE, content = "prefers dark theme"))
val userOnly = store.search(MemorySearchQuery("k8s", topK = 10, category = MemoryCategory.USER))
assertEquals(1, userOnly.size)
assertEquals("u1", userOnly[0].note.id)
val all = store.search(MemorySearchQuery("k8s", topK = 10))
assertEquals(2, all.size)
val ordered = all.map { it.note.id }.toSet()
assertTrue(ordered.containsAll(listOf("u1", "w1")))
}
}
@Test
fun listFilterByConversationId() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "g1", content = "global fact", conversationId = null))
store.upsert(note(id = "c1", content = "per-conv", conversationId = "conv-x"))
val global = store.list(conversationId = null)
assertEquals(1, global.size)
assertEquals("g1", global[0].id)
val perConv = store.list(conversationId = "conv-x")
assertEquals(1, perConv.size)
assertEquals("c1", perConv[0].id)
}
}
@Test
fun markUsedBumpsCountAndLastUsed() = runBlocking {
val root = newRoot()
openMdMemory(root).use { store ->
store.upsert(note(id = "m", content = "x", lastUsedAt = Instant.parse("2026-09-01T00:00:00Z"), useCount = 0))
store.markUsed("m", Instant.parse("2026-09-14T10:00:00Z"))
val after = store.get("m")
assertNotNull(after)
assertEquals(1, after.useCount)
assertEquals(Instant.parse("2026-09-14T10:00:00Z"), after.lastUsedAt)
}
}
}
@@ -0,0 +1,31 @@
package pw.binom.agentik.memory.md
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.incrementAndFetch
/**
* Простой потокобезопасный счётчик для генерации уникальных id в тестах.
* Работает на всех KMP-таргетах (JVM + native), в отличие от `java.util.UUID`.
*/
@OptIn(ExperimentalAtomicApi::class)
internal class AtomicCounter {
private val v = AtomicInt(0)
fun next(): Int = v.incrementAndFetch()
}
internal fun atomicCounter(): AtomicCounter = AtomicCounter()
/**
* Process-wide уникальный id — комбинация nanos + счётчика.
* Гарантирует уникальность имени временной директории при параллельных тестах.
*/
@OptIn(ExperimentalAtomicApi::class)
private val processCounter = AtomicInt(0)
@OptIn(ExperimentalAtomicApi::class)
internal fun uniqueId(): String {
val n = processCounter.incrementAndFetch()
val ts = kotlin.time.Clock.System.now().toEpochMilliseconds()
return "${ts}-${n}"
}
+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+
заметок) и семантических запросов.
+47
View File
@@ -0,0 +1,47 @@
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)
// Vector-бэкенд JVM-only: JVector не публикует KMP-таргеты, но его Java 11
// base jar работает на Android ART через scalar fallback. Для desktop JVM
// HotSpot 21+ автоматически подхватывается Panama Vector API (multirelease).
jvm()
sourceSets {
commonMain.dependencies {
api(project(":memory-api"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.test)
}
jvmMain.dependencies {
implementation(libs.jvector)
implementation(libs.sqldelight.sqlite.driver)
}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
dependencies {
add("jvmMainApi", libs.text.embedding.api)
add("jvmMainImplementation", libs.text.embedding.siglip)
}
@@ -0,0 +1,39 @@
package pw.binom.agentik.memory.vector
/**
* Провайдер эмбеддингов: превращает текст в FloatArray фиксированной размерности.
*
* Реализация по умолчанию — HTTP-вызов `POST /v1/embeddings` к OpenAI-совместимому
* API (OpenAI / litellm-proxy / vllm). С LRU-кэшом, чтобы не ходить в сеть
* на каждый search/upsert.
*/
interface EmbeddingProvider {
val dimension: Int
suspend fun embed(text: String): FloatArray
/** Batch-вариант. По умолчанию — последовательный вызов [embed]. */
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
texts.map { embed(it) }
}
/**
* Детерминированный провайдер для тестов: хеширует текст в псевдо-вектор.
* Используется только в commonTest; в продакшн заменяется на HttpEmbeddingProvider.
*/
class FakeEmbeddingProvider(override val dimension: Int = 32) : EmbeddingProvider {
override suspend fun embed(text: String): FloatArray {
val v = FloatArray(dimension)
// Простейший детерминированный seed — сумма char'ов по модулю.
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
for (i in 0 until dimension) {
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
// L2-normalize чтобы cosine работал осмысленно.
var norm = 0f
for (x in v) norm += x * x
norm = kotlin.math.sqrt(norm)
if (norm > 0f) for (i in v.indices) v[i] /= norm
return v
}
}
@@ -0,0 +1,38 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import kotlin.time.Instant
/**
* Хранилище метаданных и embeddings заметок. Реализация по умолчанию —
* SQLite (`SqliteMemoryMetaStore`).
*
* Это источник правды: [VectorMemoryIndex] (JVector) держит in-RAM ANN-индекс,
* который пересобирается из [allEntries] при старте. Вектор хранится рядом с
* метаданными — как packed little-endian Float32Array (`dim` * 4 байт).
*
* Скрывает детали backend'а от [VectorMemoryStore], который живёт в commonMain
* и не знает про SQLite.
*/
interface MemoryMetaStore : AutoCloseable {
/** Записать заметку и её embedding. Идемпотентно по [note]`.id`. */
fun put(note: MemoryNote, embedding: FloatArray)
/** Заметка по id, без вектора. */
fun get(id: String): MemoryNote?
/** Все (id, embedding) — для пересборки vector-индекса при старте. */
fun allEntries(): List<Pair<String, FloatArray>>
/** Пагинированный листинг заметок с опциональными фильтрами. */
fun list(category: MemoryCategory?, conversationId: String?, limit: Int, offset: Int): List<MemoryNote>
/** Удалить заметку и её embedding. Возвращает true если запись была. */
fun delete(id: String): Boolean
/** Обновить `last_used_at` (и увеличить `use_count`) для [id]. */
fun markUsed(id: String, at: Instant)
override fun close()
}
@@ -0,0 +1,63 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
/**
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1].
* Чем ближе к 1.0, тем семантически ближе query к заметке.
*/
data class ScoredVector(
val id: String,
val score: Float,
)
/**
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
*
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
*/
interface MemoryVectorIndex : AutoCloseable {
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
val dimension: Int
/** Количество записей в индексе. */
suspend fun size(): Long
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
suspend fun add(id: String, embedding: FloatArray)
/** Удалить запись по [id]. Возвращает true если запись была. */
suspend fun remove(id: String): Boolean
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id (например, по категории). */
suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean = { true },
): List<ScoredVector>
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
suspend fun flush()
override fun close()
}
/**
* Доп. контекст для vector-индекса: фильтр по категории и conversationId
* передаётся через замыкание, которое получает [MemoryNote]. Так [MemoryStore]
* остаётся единственным источником правды по метаданным.
*/
fun noteMatches(
note: MemoryNote,
category: MemoryCategory? = null,
conversationId: String? = null,
): Boolean {
if (category != null && note.category != category) return false
if (conversationId != null && note.conversationId != conversationId) return false
return true
}
@@ -0,0 +1,96 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import kotlin.math.exp
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* MemoryStore поверх (index + metadata). Метаданные заметок хранятся
* в [metaStore] (SQLite-таблица), эмбеддинги — в [index] (JVector on-disk graph).
*
* Контракт MemoryStore требует, чтобы [upsert] атомарно обновлял и метаданные,
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
* потом re-rank по recency.
*
* [embeddingProvider] обязателен — используется для эмбеддинга контента при
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
*/
class VectorMemoryStore(
private val index: MemoryVectorIndex,
private val metaStore: MemoryMetaStore,
private val embeddingProvider: EmbeddingProvider,
) : MemoryStore {
private val mutex = Mutex()
private val _events = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 64)
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
val embedding = embeddingProvider.embed(note.content)
metaStore.put(note, embedding)
index.add(note.id, embedding)
_events.emit(MemoryStoreEvent.Upserted(note))
}
override suspend fun get(id: String): MemoryNote? = metaStore.get(id)
override suspend fun list(
category: MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
val queryEmbedding = embeddingProvider.embed(query.query)
val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
// через [metaStore.get] + [noteMatches] отрежет лишних.
val candidates = index.search(
query = queryEmbedding,
k = overFetch,
filter = { true },
)
// Re-rank: 0.7 * cosine + 0.3 * recency_weight
// recency_weight = exp(-age_days / 30) — half-life месяц.
val now = Clock.System.now()
val scored = candidates.mapNotNull { sv ->
val note = metaStore.get(sv.id) ?: return@mapNotNull null
if (!noteMatches(note, query.category, query.conversationId)) return@mapNotNull null
val ageDays = (now - note.lastUsedAt).inWholeDays.toDouble()
val recency = exp(-ageDays / 30.0).toFloat()
val finalScore = 0.7f * sv.score + 0.3f * recency
MemorySearchResult(note = note, score = finalScore)
}
return scored.sortedByDescending { it.score }.take(query.topK)
}
override suspend fun delete(id: String): Boolean = mutex.withLock {
val existed = metaStore.delete(id)
if (existed) {
index.remove(id)
_events.emit(MemoryStoreEvent.Deleted(id))
}
existed
}
override suspend fun markUsed(id: String, at: Instant) {
metaStore.markUsed(id, at)
}
override fun close() {
index.close()
metaStore.close()
}
}
@@ -0,0 +1,168 @@
package pw.binom.agentik.memory.vector
import io.github.jbellis.jvector.graph.GraphIndexBuilder
import io.github.jbellis.jvector.graph.GraphSearcher
import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues
import io.github.jbellis.jvector.graph.OnHeapGraphIndex
import io.github.jbellis.jvector.graph.SearchResult
import io.github.jbellis.jvector.graph.similarity.BuildScoreProvider
import io.github.jbellis.jvector.util.Bits
import io.github.jbellis.jvector.vector.VectorizationProvider
import io.github.jbellis.jvector.vector.VectorSimilarityFunction
import pw.binom.agentik.memory.MemoryNote
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
/**
* In-RAM ANN-индекс поверх JVector.
*
* Семантика хранения: **источник правды — SQLite (см. MemoryMetaStore)**.
* Этот класс держит в heap'е [OnHeapGraphIndex] + mapping id ↔ ordinal и
* пересобирается из [seedEntries] при конструировании. На каждом [add]/[remove]
* граф перестраивается полностью (для 10K vectors это <100ms).
*
* **Что НЕ делается**: persist через OnDiskGraphIndex. JVector'у для записи
* на диск нужна Feature с INLINE_VECTORS, которая (в текущей версии 4.0.0)
* конфигурируется отдельно и сложно. SQLite BLOB дешевле и проще — она и
* хранит embedding'и. Граф реконструируется из SQLite при старте.
*
* Потокобезопасность: [ReentrantReadWriteLock] — параллельные [search] ок,
* [add]/[remove] — эксклюзивно.
*/
class JVectorMemoryIndex(
override val dimension: Int,
seedEntries: List<Pair<String, FloatArray>> = emptyList(),
) : MemoryVectorIndex {
init {
require(seedEntries.all { it.second.size == dimension }) {
"all seed embeddings must have dimension=$dimension"
}
require(seedEntries.map { it.first }.toSet().size == seedEntries.size) {
"duplicate ids in seedEntries"
}
}
private val rwLock = ReentrantReadWriteLock()
private val vts = VectorizationProvider.getInstance().getVectorTypeSupport()
private val similarity = VectorSimilarityFunction.COSINE
// In-RAM state. Защищён rwLock.
private val idToOrdinal = LinkedHashMap<String, Int>()
private val ordinalToId = ArrayList<String>(seedEntries.size + 16)
private val ordinalToVector = ArrayList<FloatArray>(seedEntries.size + 16)
private val deleted = java.util.BitSet()
private var graph: OnHeapGraphIndex? = null
init {
seedEntries.forEach { (id, vec) ->
val ord = ordinalToId.size
idToOrdinal[id] = ord
ordinalToId.add(id)
ordinalToVector.add(vec)
}
if (ordinalToId.isNotEmpty()) {
graph = rebuildFromScratch()
}
}
override suspend fun size(): Long = rwLock.read {
(ordinalToId.size - deleted.cardinality()).toLong()
}
override suspend fun add(id: String, embedding: FloatArray) = rwLock.write {
require(embedding.size == dimension) {
"embedding size ${embedding.size} != dimension $dimension"
}
val existing = idToOrdinal[id]
if (existing != null) {
ordinalToVector[existing] = embedding
deleted.clear(existing)
} else {
val ord = ordinalToId.size
idToOrdinal[id] = ord
ordinalToId.add(id)
ordinalToVector.add(embedding)
}
rebuildAndSwapGraph()
}
override suspend fun remove(id: String): Boolean = rwLock.write {
val ord = idToOrdinal[id] ?: return false
deleted.set(ord)
rebuildAndSwapGraph()
true
}
override suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean,
): List<ScoredVector> = rwLock.read {
require(query.size == dimension) {
"query size ${query.size} != dimension $dimension"
}
if (k <= 0 || graph == null) return emptyList()
val activeOrdinals = (0 until ordinalToId.size).filter { !deleted.get(it) }
if (activeOrdinals.isEmpty()) return emptyList()
val vectors = activeOrdinals.map { vts.createFloatVector(ordinalToVector[it]) }
val ravv = ListRandomAccessVectorValues(vectors, dimension)
val queryVec = vts.createFloatVector(query)
val result: SearchResult = GraphSearcher.search(
queryVec,
k.coerceAtMost(activeOrdinals.size),
ravv,
similarity,
graph!!,
Bits.ALL,
)
val nodes: Array<SearchResult.NodeScore> = result.getNodes()
val out = ArrayList<ScoredVector>(nodes.size)
for (ns in nodes) {
val realOrd = activeOrdinals[ns.node]
out.add(ScoredVector(id = ordinalToId[realOrd], score = ns.score))
}
// [filter] применяется в [VectorMemoryStore] по MemoryNote (там есть category/convId).
// Контракт JVector — фильтрация через Bits, что здесь неудобно, поэтому
// делегируем фильтр наверх.
out
}
override suspend fun flush() {
// No-op: граф в RAM, источник правды — SQLite. flush не требуется.
}
override fun close() {
rwLock.write {
graph?.close()
graph = null
}
}
private fun rebuildAndSwapGraph() {
val newGraph = rebuildFromScratch()
val old = graph
graph = newGraph
old?.close()
}
private fun rebuildFromScratch(): OnHeapGraphIndex {
val activeOrdinals = (0 until ordinalToId.size).filter { !deleted.get(it) }
val vectors = activeOrdinals.map { vts.createFloatVector(ordinalToVector[it]) }
val ravv = ListRandomAccessVectorValues(vectors, dimension)
val bsp = BuildScoreProvider.randomAccessScoreProvider(ravv, similarity)
// Параметры графа по умолчанию (как в JVector README):
// - M (max degree) = 16..32 — больше = точнее, медленнее
// - efConstruction = 100..200 — больше = точнее, дольше строить
// Для нашего масштаба (10K) берём средние значения.
val M = 16
val efConstruction = 100
val neighborOverflow = 1.2f
val alpha = 1.2f
return GraphIndexBuilder(bsp, dimension, M, efConstruction, neighborOverflow, alpha).use { builder ->
builder.build(ravv)
}
}
}
@@ -0,0 +1,241 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Хранилище метаданных заметок + их эмбеддингов в SQLite.
*
* Схема (`memory_note_meta`):
* - `id` — TEXT PRIMARY KEY
* - `category`, `source` — TEXT (id enum'ов)
* - `content` — TEXT
* - `created_at`, `last_used_at` — INTEGER (epoch ms)
* - `use_count` — INTEGER
* - `conversation_id` — TEXT NULL
* - `embedding` — BLOB (packed Float32Array, dim * 4 bytes, little-endian)
*
* Это **источник правды** для vector-бэкенда. JVector-индекс — in-RAM,
* пересобирается из [allEntries] при старте. См. [JVectorMemoryIndex].
*
* Можно шарить один `agentik.db` с conversation DB — таблицы не пересекаются.
*
* Потокобезопасность: рассчитывает на single-connection-per-instance,
* синхронизация на уровне [VectorMemoryStore] (mutex на upsert/delete).
*/
class SqliteMemoryMetaStore(
private val conn: Connection,
private val dimension: Int,
) : MemoryMetaStore {
/** Открыть отдельный файл (например, `~/.agentik/agentik.db` для шаринга). */
constructor(jdbcUrl: String, dimension: Int) : this(
DriverManager.getConnection(jdbcUrl).apply {
createStatement().use { st ->
st.execute("PRAGMA foreign_keys = ON")
st.execute("PRAGMA journal_mode = WAL")
}
},
dimension,
)
private val initialized = java.util.concurrent.atomic.AtomicBoolean(false)
private fun ensureSchema() {
if (initialized.get()) return
conn.createStatement().use { st ->
st.execute(
"""
CREATE TABLE IF NOT EXISTS memory_note_meta (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
use_count INTEGER NOT NULL DEFAULT 0,
conversation_id TEXT,
source TEXT NOT NULL,
embedding BLOB NOT NULL
)
""".trimIndent()
)
st.execute("CREATE INDEX IF NOT EXISTS memory_note_meta_cat ON memory_note_meta(category)")
st.execute("CREATE INDEX IF NOT EXISTS memory_note_meta_lu ON memory_note_meta(last_used_at DESC)")
}
initialized.set(true)
}
override fun put(note: MemoryNote, embedding: FloatArray) {
ensureSchema()
require(embedding.size == dimension) {
"embedding size ${embedding.size} != dimension $dimension"
}
val blob = embedding.toLittleEndianBytes()
conn.prepareStatement(
"""
INSERT INTO memory_note_meta(id, category, content, created_at, last_used_at,
use_count, conversation_id, source, embedding)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
category = excluded.category,
content = excluded.content,
created_at = excluded.created_at,
last_used_at = excluded.last_used_at,
use_count = excluded.use_count,
conversation_id = excluded.conversation_id,
source = excluded.source,
embedding = excluded.embedding
""".trimIndent()
).use { ps ->
ps.setString(1, note.id)
ps.setString(2, note.category.id)
ps.setString(3, note.content)
ps.setLong(4, note.createdAt.toEpochMilliseconds())
ps.setLong(5, note.lastUsedAt.toEpochMilliseconds())
ps.setInt(6, note.useCount)
ps.setString(7, note.conversationId)
ps.setString(8, note.source.id)
ps.setBytes(9, blob)
ps.executeUpdate()
}
}
override fun get(id: String): MemoryNote? {
ensureSchema()
conn.prepareStatement(
"SELECT category, content, created_at, last_used_at, use_count, conversation_id, source FROM memory_note_meta WHERE id = ?"
).use { ps ->
ps.setString(1, id)
ps.executeQuery().use { rs ->
return if (rs.next()) rs.toNote(id) else null
}
}
}
override fun allEntries(): List<Pair<String, FloatArray>> {
ensureSchema()
conn.prepareStatement(
"SELECT id, embedding FROM memory_note_meta"
).use { ps ->
ps.executeQuery().use { rs ->
val out = ArrayList<Pair<String, FloatArray>>()
while (rs.next()) {
val id = rs.getString("id")
val blob = rs.getBytes("embedding") ?: continue
out.add(id to blob.toFloatArray(dimension))
}
return out
}
}
}
override fun list(category: MemoryCategory?, conversationId: String?, limit: Int, offset: Int): List<MemoryNote> {
ensureSchema()
val where = buildString {
val clauses = mutableListOf<String>()
if (category != null) clauses += "category = ?"
if (conversationId != null) clauses += "conversation_id = ?"
if (clauses.isNotEmpty()) append("WHERE ").append(clauses.joinToString(" AND "))
}
val sql = "SELECT id, category, content, created_at, last_used_at, use_count, conversation_id, source FROM memory_note_meta $where ORDER BY last_used_at DESC LIMIT ? OFFSET ?"
return conn.prepareStatement(sql).use { ps ->
var idx = 1
if (category != null) ps.setString(idx++, category.id)
if (conversationId != null) ps.setString(idx++, conversationId)
ps.setInt(idx++, limit)
ps.setInt(idx, offset)
ps.executeQuery().use { rs ->
buildList {
while (rs.next()) add(rs.toNote(rs.getString("id")))
}
}
}
}
override fun delete(id: String): Boolean {
ensureSchema()
return conn.prepareStatement("DELETE FROM memory_note_meta WHERE id = ?").use { ps ->
ps.setString(1, id)
ps.executeUpdate() > 0
}
}
override fun markUsed(id: String, at: Instant) {
ensureSchema()
conn.prepareStatement(
"UPDATE memory_note_meta SET use_count = use_count + 1, last_used_at = ? WHERE id = ?"
).use { ps ->
ps.setLong(1, at.toEpochMilliseconds())
ps.setString(2, id)
ps.executeUpdate()
}
}
override fun close() {
conn.close()
}
companion object {
/** Default `now` для тестов. */
internal fun now(): Instant = Clock.System.now()
/**
* Открывает (или создаёт) SQLite-БД по пути [dbPath], инициализирует
* схему `memory_note_meta` и возвращает [SqliteMemoryMetaStore].
*/
fun open(dbPath: String, dimension: Int): SqliteMemoryMetaStore {
val conn = DriverManager.getConnection("jdbc:sqlite:$dbPath")
return SqliteMemoryMetaStore(conn, dimension)
}
}
}
private fun ResultSet.toNote(id: String): MemoryNote {
val catId = getString("category")
val srcId = getString("source")
val createdMs = getLong("created_at")
val lastUsedMs = getLong("last_used_at")
return MemoryNote(
id = id,
category = MemoryCategory.fromId(catId),
content = getString("content"),
createdAt = Instant.fromEpochMilliseconds(createdMs),
lastUsedAt = Instant.fromEpochMilliseconds(lastUsedMs),
useCount = getInt("use_count"),
conversationId = getString("conversation_id"),
source = MemorySource.fromId(srcId),
)
}
/**
* Little-endian packed Float32Array → byte[].
* JVector ожидает packed float, а JVM по умолчанию big-endian — переставляем явно.
*/
internal fun FloatArray.toLittleEndianBytes(): ByteArray {
val bb = ByteBuffer.allocate(size * 4).order(ByteOrder.LITTLE_ENDIAN)
bb.asFloatBuffer().put(this)
return bb.array()
}
/**
* Обратное преобразование: byte[] → FloatArray (little-endian → JVM-native).
* Проверяет длину против [expectedDim].
*/
internal fun ByteArray.toFloatArray(expectedDim: Int): FloatArray {
require(size == expectedDim * 4) {
"blob size $size != expected ${expectedDim * 4} bytes (dim=$expectedDim)"
}
val bb = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN)
val out = FloatArray(expectedDim)
bb.asFloatBuffer().get(out)
return out
}
@@ -0,0 +1,104 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.ReviewedTurn
/**
* Бандл компонентов vector-бэкенда памяти — то же, что
* [pw.binom.agentik.memory.md.MdMemorySystem], но на базе JVector + SQLite + LLM-эмбеддингов.
*
* Содержит:
* - [store] — `MemoryStore` (vector-backed)
* - [prefetcher] — top-K через vector search + `markUsed`
* - [reviewer] — простая эвристика (vector-рекомендации оставим для Phase 5 LlmMemoryReviewer)
*
* Закрытие через [close] освобождает SQLite-коннекшен и (если есть) HTTP-клиент эмбеддингов.
*/
class VectorMemorySystem(
override val store: MemoryStore,
override val prefetcher: MemoryPrefetcher,
override val reviewer: MemoryReviewer,
private val closables: List<AutoCloseable>,
) : MemorySystem {
override fun close() {
closables.forEach { runCatching { it.close() } }
}
companion object {
/**
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
*
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient
* @param topK размер top-K для prefetch
*/
fun open(
dbPath: String,
embedding: EmbeddingProvider,
topK: Int = 10,
): VectorMemorySystem {
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension)
// Граф пересобирается из SQLite (источник правды): без seed'ов
// после рестарта in-RAM индекс пуст и search возвращал бы [],
// пока не появятся новые upsert'ы.
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries())
val store = VectorMemoryStore(index, metaStore, embedding)
val prefetcher = VectorPrefetcher(store, topK)
val reviewer = VectorMemoryReviewer(store)
return VectorMemorySystem(
store = store,
prefetcher = prefetcher,
reviewer = reviewer,
closables = listOfNotNull(
metaStore,
index,
embedding as? AutoCloseable,
),
)
}
}
}
/**
* `MemoryPrefetcher` поверх vector-store: top-K через cosine similarity + recency re-rank.
* На каждом результате вызывает `store.markUsed(id)`.
*/
class VectorPrefetcher(
private val store: MemoryStore,
private val defaultTopK: Int,
) : MemoryPrefetcher {
override suspend fun prefetch(
query: String,
topK: Int,
category: MemoryCategory?,
): List<MemoryNote> {
val results = store.search(
MemorySearchQuery(
query = query,
topK = topK.takeIf { it > 0 } ?: defaultTopK,
category = category,
)
)
results.forEach { store.markUsed(it.note.id) }
return results.map { it.note }
}
}
/**
* Простейший reviewer для vector-бэкенда: не извлекает новых фактов из ходов,
* только дедуплицирует/маркирует использованные. Для настоящего LLM-driven review'а
* (Hermes-style one-shot с whitelist tools) см. Phase 5 — [LlmMemoryReviewer].
*/
class VectorMemoryReviewer(
private val store: MemoryStore,
) : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
MemoryReviewDecision()
}
@@ -0,0 +1,103 @@
package pw.binom.agentik.memory.vector.embedding
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import pw.binom.agentik.memory.vector.EmbeddingProvider
/**
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
* Используется при memory-backend=vector.
*
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов
* к API на одинаковых промптах.
*
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
* @param apiKey bearer-токен
* @param model имя модели эмбеддингов, например `text-embedding-3-small`
* @param dimension размерность вектора (по умолчанию 1536 — text-embedding-3-small)
* @param cacheSize ёмкость LRU-кэша (default 256)
*/
class HttpEmbeddingClient(
private val apiUrl: String,
private val apiKey: String,
private val model: String,
override val dimension: Int,
cacheSize: Int = 256,
) : EmbeddingProvider, AutoCloseable {
private val cache = LruCache<String, FloatArray>(cacheSize)
private val http: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
private val json = Json { ignoreUnknownKeys = true }
override suspend fun embed(text: String): FloatArray {
cache.get(text)?.let { return it }
val vector = fetchEmbedding(text)
cache.put(text, vector)
return vector
}
private fun fetchEmbedding(text: String): FloatArray {
val url = URI.create("$apiUrl/v1/embeddings")
val body = buildJsonObject {
put("model", JsonPrimitive(model))
put("input", JsonPrimitive(text))
}.toString()
val request = HttpRequest.newBuilder(url)
.header("Authorization", "Bearer $apiKey")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(30))
.build()
val response = http.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() !in 200..299) {
error("embedding API error ${response.statusCode()}: ${response.body()}")
}
val parsed = json.parseToJsonElement(response.body()).jsonObject
val data = parsed["data"]?.jsonArray ?: error("missing 'data' in embedding response")
val firstData = data[0].jsonObject
val embeddingArray = firstData["embedding"]?.jsonArray ?: error("missing 'embedding' array")
val out = FloatArray(embeddingArray.size)
for ((i, v: JsonElement) in embeddingArray.withIndex()) {
out[i] = v.jsonPrimitive.content.toFloat()
}
require(out.size == dimension) {
"embedding dim mismatch: got ${out.size}, expected $dimension (model=$model)"
}
return out
}
override fun close() = http.close()
}
private class LruCache<K, V>(private val capacity: Int) {
private val map = LinkedHashMap<K, V>(capacity, 0.75f, true)
private val lock = Any()
fun get(key: K): V? = synchronized(lock) {
map[key]
}
fun put(key: K, value: V) = synchronized(lock) {
map[key] = value
if (map.size > capacity) {
val firstKey = map.keys.iterator().next()
map.remove(firstKey)
}
}
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.memory.vector.embedding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.vector.EmbeddingProvider
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
/**
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
*
* Особенности:
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU),
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать
* доступ из нескольких корутин (ONNX-сессия не reentrant).
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр
* `dimension` в конструкторе не принимаем — берём через [probeDimension].
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется —
* легко добавить.
*
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
* Скачать: см. README репы `text-embedding-kmp`.
*/
class SiglipEmbeddingProvider(
modelPath: String,
tokenizerPath: String,
) : EmbeddingProvider, AutoCloseable {
private val extractor: TextEmbeddingExtractor =
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
override val dimension: Int = run {
val probe = extractor.embed("probe")
probe.dim
}
private val mutex = Mutex()
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) {
mutex.withLock { extractor.embed(text).values }
}
override fun close() {
extractor.close()
}
}
@@ -0,0 +1,74 @@
package pw.binom.agentik.memory.vector
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class JVectorMemoryIndexTest {
private fun makeVec(seed: Int, dim: Int): FloatArray {
val v = FloatArray(dim)
var s = seed.toLong() and 0xFFFFFFFFL
for (i in 0 until dim) {
s = (s * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((s.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
var norm = 0f
for (x in v) norm += x * x
norm = kotlin.math.sqrt(norm)
if (norm > 0f) for (i in v.indices) v[i] /= norm
return v
}
@Test
fun emptySearchReturnsEmpty() = runTest {
val idx = JVectorMemoryIndex(dimension = 8, seedEntries = emptyList())
val out = idx.search(makeVec(1, 8), k = 5) { true }
assertTrue(out.isEmpty())
idx.close()
}
@Test
fun addAndSearchReturnsNearest() = runTest {
val dim = 32
val idx = JVectorMemoryIndex(dimension = dim, seedEntries = emptyList())
// 50 случайных векторов, id'ы = "v0".."v49"
for (i in 0 until 50) {
idx.add("v$i", makeVec(i + 100, dim))
}
assertEquals(50L, idx.size())
// Запрос = vec с seed 105 (= v5)
val results = idx.search(makeVec(105, dim), k = 5) { true }
assertEquals(5, results.size)
// v5 должен быть среди top-k (топовый результат должен быть тем же seed'ом).
assertEquals("v5", results.first().id)
idx.close()
}
@Test
fun removeHidesFromSearch() = runTest {
val dim = 16
val idx = JVectorMemoryIndex(dimension = dim, seedEntries = emptyList())
for (i in 0 until 10) {
idx.add("n$i", makeVec(i, dim))
}
assertTrue(idx.remove("n3"))
val results = idx.search(makeVec(3, dim), k = 10) { true }
assertEquals(9, results.size)
assertTrue(results.none { it.id == "n3" })
idx.close()
}
@Test
fun reAddReusesOrdinal() = runTest {
val dim = 8
val idx = JVectorMemoryIndex(dimension = dim, seedEntries = emptyList())
idx.add("x", makeVec(1, dim))
idx.add("x", makeVec(2, dim)) // overwrite
assertEquals(1L, idx.size())
idx.close()
}
}
@@ -0,0 +1,166 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import java.io.File
import java.sql.DriverManager
import java.util.UUID
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class SqliteMemoryMetaStoreTest {
private lateinit var file: File
private lateinit var store: SqliteMemoryMetaStore
private val dim = 32
@BeforeTest
fun setup() {
file = File.createTempFile("agentik-vec-test-", ".db").also { it.deleteOnExit() }
store = SqliteMemoryMetaStore(
"jdbc:sqlite:${file.absolutePath}",
dimension = dim,
)
}
@AfterTest
fun teardown() {
store.close()
}
private fun makeNote(id: String, content: String, cat: MemoryCategory = MemoryCategory.WORLD): MemoryNote {
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
return MemoryNote(
id = id,
category = cat,
content = content,
createdAt = now,
lastUsedAt = now,
useCount = 0,
conversationId = null,
source = MemorySource.USER_EXPLICIT,
)
}
private fun makeVec(seed: Int): FloatArray {
val v = FloatArray(dim)
var s = seed.toLong() and 0xFFFFFFFFL
for (i in 0 until dim) {
s = (s * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((s.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
return v
}
@Test
fun putAndGetRoundTrip() {
val note = makeNote("n1", "hello world")
val vec = makeVec(42)
store.put(note, vec)
val got = store.get("n1")
assertNotNull(got)
assertEquals("hello world", got.content)
assertEquals(MemoryCategory.WORLD, got.category)
}
@Test
fun allEntriesReturnsAll() {
repeat(5) { i ->
store.put(makeNote("n$i", "text $i"), makeVec(i))
}
val all = store.allEntries()
assertEquals(5, all.size)
assertEquals(setOf("n0", "n1", "n2", "n3", "n4"), all.map { it.first }.toSet())
all.forEach { (_, v) ->
assertEquals(dim, v.size)
}
}
@Test
fun listFiltersByCategory() {
store.put(makeNote("w1", "world 1", MemoryCategory.WORLD), makeVec(1))
store.put(makeNote("u1", "user 1", MemoryCategory.USER), makeVec(2))
store.put(makeNote("w2", "world 2", MemoryCategory.WORLD), makeVec(3))
val worlds = store.list(category = MemoryCategory.WORLD, conversationId = null, limit = 10, offset = 0)
assertEquals(2, worlds.size)
assertTrue(worlds.all { it.category == MemoryCategory.WORLD })
val users = store.list(category = MemoryCategory.USER, conversationId = null, limit = 10, offset = 0)
assertEquals(1, users.size)
assertEquals("u1", users.first().id)
}
@Test
fun deleteRemovesNote() {
store.put(makeNote("x", "to delete"), makeVec(7))
assertTrue(store.delete("x"))
assertNull(store.get("x"))
assertTrue(store.allEntries().isEmpty())
// Второй delete возвращает false.
assertEquals(false, store.delete("x"))
}
@Test
fun markUsedIncrementsCount() {
val note = makeNote("y", "used")
store.put(note, makeVec(8))
store.markUsed("y", Instant.fromEpochMilliseconds(1000L))
store.markUsed("y", Instant.fromEpochMilliseconds(2000L))
val got = store.get("y")
assertNotNull(got)
assertEquals(2, got.useCount)
assertEquals(Instant.fromEpochMilliseconds(2000L), got.lastUsedAt)
}
@Test
fun embeddingsAreLittleEndian() {
// Проверяем что BLOB читается в little-endian: первая 4 байта = first float.
// dim=32 => vec длиной 32.
val vec = FloatArray(dim) { i -> (i + 1).toFloat() }
val note = makeNote("le", "le test")
store.put(note, vec)
val rawBytes = DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { conn ->
conn.prepareStatement("SELECT embedding FROM memory_note_meta WHERE id = ?").use { ps ->
ps.setString(1, "le")
ps.executeQuery().use { rs ->
rs.next()
rs.getBytes("embedding")
}
}
}
// В little-endian IEEE-754: 1.0f = 0x00 0x00 0x80 0x3F (младший байт первый).
assertEquals(0x00.toByte(), rawBytes[0])
assertEquals(0x00.toByte(), rawBytes[1])
assertEquals(0x80.toByte(), rawBytes[2])
assertEquals(0x3F.toByte(), rawBytes[3])
// 2.0f = 0x00 0x00 0x00 0x40
assertEquals(0x00.toByte(), rawBytes[4])
assertEquals(0x00.toByte(), rawBytes[5])
assertEquals(0x00.toByte(), rawBytes[6])
assertEquals(0x40.toByte(), rawBytes[7])
}
@Test
fun reopenKeepsData() {
store.put(makeNote("persistent", "survives restart"), makeVec(99))
store.close()
// Переоткрываем тот же файл — данные должны быть.
store = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
val got = store.get("persistent")
assertNotNull(got)
assertEquals("survives restart", got.content)
val entries = store.allEntries()
assertEquals(1, entries.size)
assertEquals(dim, entries[0].second.size)
// Round-trip работает (byte-order little-endian — проверено в отдельном тесте).
// Здесь просто убеждаемся что BLOB распарсился в массив нужной длины.
}
}
@@ -0,0 +1,156 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySource
import java.io.File
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class VectorMemoryStoreTest {
private lateinit var file: File
private lateinit var metaStore: SqliteMemoryMetaStore
private lateinit var index: JVectorMemoryIndex
private lateinit var store: VectorMemoryStore
private val dim = 16
@BeforeTest
fun setup() {
file = File.createTempFile("agentik-vms-test-", ".db").also { it.deleteOnExit() }
metaStore = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
// Загружаем начальные entries из metaStore (на случай если что-то там есть).
val seedEntries = metaStore.allEntries()
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim))
}
@AfterTest
fun teardown() {
store.close()
}
private fun makeNote(id: String, content: String, cat: MemoryCategory = MemoryCategory.WORLD): MemoryNote {
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
return MemoryNote(
id = id,
category = cat,
content = content,
createdAt = now,
lastUsedAt = now,
useCount = 0,
conversationId = null,
source = MemorySource.USER_EXPLICIT,
)
}
@Test
fun upsertAndGet() = runTest {
val note = makeNote("a", "alpha")
store.upsert(note)
val got = store.get("a")
assertNotNull(got)
assertEquals("alpha", got.content)
assertEquals(1L, index.size())
}
@Test
fun searchFindsNearest() = runTest {
// Несколько заметок; запрос — близкий к "hello world" по семантике.
store.upsert(makeNote("a", "kotlin coroutines async"))
store.upsert(makeNote("b", "java virtual machine"))
store.upsert(makeNote("c", "the quick brown fox"))
store.upsert(makeNote("d", "asynchronous programming paradigms"))
val results = store.search(MemorySearchQuery(query = "kotlin async programming", topK = 3))
assertTrue(results.isNotEmpty())
assertTrue(results.size <= 3)
// Сортировка descending — первый score >= последнего.
if (results.size >= 2) {
assertTrue(results[0].score >= results.last().score)
}
}
@Test
fun searchFiltersByCategory() = runTest {
store.upsert(makeNote("w1", "world thing 1", MemoryCategory.WORLD))
store.upsert(makeNote("u1", "user thing 1", MemoryCategory.USER))
store.upsert(makeNote("w2", "world thing 2", MemoryCategory.WORLD))
val worldResults = store.search(
MemorySearchQuery(query = "thing", topK = 10, category = MemoryCategory.WORLD)
)
assertTrue(worldResults.isNotEmpty())
assertTrue(worldResults.all { it.note.category == MemoryCategory.WORLD })
// user заметка не должна попасть в результат даже если она "ближе" по эмбеддингу.
assertTrue(worldResults.none { it.note.id == "u1" })
}
@Test
fun deleteRemovesBoth() = runTest {
store.upsert(makeNote("x", "to delete"))
assertEquals(1L, index.size())
assertTrue(store.delete("x"))
assertNull(store.get("x"))
assertEquals(0L, index.size())
}
@Test
fun upsertEmitsEvent() = runTest {
// MutableSharedFlow без replay: подписчик должен быть ДО emit.
// backgroundScope — это TestScope'овый scope, авто-отменяется при teardown.
val received = kotlinx.coroutines.CompletableDeferred<pw.binom.agentik.memory.MemoryStoreEvent>()
backgroundScope.launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
store.events().collect { received.complete(it); return@collect }
}
store.upsert(makeNote("e", "eventful"))
val ev = withTimeout(1000) { received.await() }
assertEquals("e", (ev as pw.binom.agentik.memory.MemoryStoreEvent.Upserted).note.id)
}
@Test
fun reopenReconstructsIndexFromSqlite() = runTest {
store.upsert(makeNote("p", "persistent 1"))
store.upsert(makeNote("q", "persistent 2"))
// Close → re-open.
store.close()
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
val seedEntries = meta2.allEntries()
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim))
try {
assertEquals(2L, idx2.size())
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
assertTrue(results.any { it.note.id == "p" })
} finally {
store2.close()
}
}
@Test
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
// из SQLite — иначе после рестарта search возвращает [] до первого upsert.
val first = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
first.close()
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
try {
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
assertTrue(results.any { it.note.id == "r" })
} finally {
second.close()
}
}
}
@@ -0,0 +1,51 @@
package pw.binom.agentik.memory.vector.embedding
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Smoke-test SiglipEmbeddingProvider.
*
* Если файлы модели не найдены (по дефолту `/tmp/text-emb-model/`),
* тест пропускается через [assumeModelAvailable]. Если найдены —
* проверяется, что провайдер открывается, возвращает валидный эмбеддинг
* правильной размерности, и закрывается чисто.
*/
class SiglipEmbeddingProviderTest {
@Test
fun `dimension is 768 when model loads successfully`() {
val modelDir = File("/tmp/text-emb-model")
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
}
SiglipEmbeddingProvider(
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
).use { provider ->
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") }
assertEquals(768, v.size)
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
}
}
@Test
fun `missing model file fails with clear error`() {
val tmpDir = kotlin.io.path.createTempDirectory(prefix = "no-model-").toFile()
val nonExistent = File(tmpDir, "does-not-exist.onnx")
assertFailsWith<Exception> {
SiglipEmbeddingProvider(
modelPath = nonExistent.absolutePath,
tokenizerPath = nonExistent.absolutePath,
).use { it.dimension }
}
}
private inline fun assume(condition: Boolean, message: () -> String) {
org.junit.Assume.assumeTrue(message(), condition)
}
}

Some files were not shown because too many files have changed in this diff Show More