65 Commits

Author SHA1 Message Date
subochev 9d310c5fd0 ci: убрать upload-artifact@v4 — GHESNotSupportedError валил джоб после зелёных тестов
ci / JVM build + tests (push) Successful in 6m5s
release / Publish KMP libraries → caffeine Nexus (release) Successful in 32s
2026-09-18 22:19:39 +03:00
subochev 741ad8963d ci: UTF-8 локаль + ASCII-дефис в именах тестов; release через общий publish action
ci / JVM build + tests (push) Failing after 2m44s
- ci.yml/release.yml: LANG/LC_ALL=C.UTF-8 — иначе Kotlin-компилятор падает
  с InvalidPathException на именах тестов с типографским тире (LANG=C → ASCII)
- имена тестов: типографское тире U+2014 заменено на ASCII-дефис (16 шт)
- release.yml переведён на общий composite-action subochev/devops/publish@main
  (как у asr-kmp/litert-kmp/embedder-kmp); версия = имя тега релиза
2026-09-18 22:15:51 +03:00
subochev 2634e0e204 chore: ignore .tasks/ (internal review scratch dir)
ci / JVM build + tests (push) Failing after 2m0s
2026-09-18 21:11:15 +03:00
subochev ac5d209fce refactor(standalone): extract modules, event-driven background, AppConfig
ci / JVM build + tests (push) Failing after 2m5s
Standalone refactor — modularity + correctness improvements after
STANDALONE-REVIEW findings. Touches ~30 files. Build green, 178 tests pass.

(1) Module extractions — generic components out of :standalone:

  • :llm-tools (new KMP module, package pw.binom.agentik.llm.tools)
    - LlmReflector, SkillMiner, LlmMemoryReviewer, LiteLlmContextCompactor
    - Parsers: ReflectionParser, SkillMiningParser, ReviewDecisionParser
    - Prompts: ReflectionPrompts, SkillMiningPrompts, ReviewPrompts

  • :mcp-bridge (new JVM module, package pw.binom.agentik.mcp.bridge)
    - McpConfig, McpRegistry, McpLiteToolAdapter

  • NamedTool moved from :standalone to :agent-toolsets/commonMain
    - Generic (name + LiteTool) wrapper, used by both :mcp-bridge
      and :standalone's tool dispatcher

  :standalone loses ~1400 lines, depends on the two new modules.

(2) Background work → event-driven (no more interval-polling):

  • New :standalone/agent/BackgroundEvents.kt — internal event bus:
    - ToolCallEvent.Succeeded/Failed (emitted by ToolDispatcher after invoke)
    - CompactionEvent.Triggered (emitted by CompactionCoordinator pre-delete)
    - ConversationLifecycleEvent.Closing (emitted by ConversationLoop.close)

  • BackgroundScheduler rewritten as event subscriber:
    - On Closing: final reflection + skill mining (last-chance extraction)
    - On Compaction (turnsToDelete > 10): skill mining (debounced 60s)
    - On ToolFailure x2 in 60s window: reflection (debounced 5min)
    - Dropped: maybeScheduleReview/Reflection/SkillMining (interval-based)
    - Dropped config: memoryReviewInterval, reflectionInterval, skillMiningInterval

  • ToolDispatcher emits ToolCallEvent after each invoke.
  • CompactionCoordinator emits CompactionEvent before workingMemory.compact().
  • ConversationLoop.close() emits Closing BEFORE agentScope.cancel() so the
    subscription gets to run final reflection/mining.

  Net effect: typical 30-turn conversation runs ~38 LLM calls (was: 30 main +
  3 review + 3 reflection + 2 mining). With event-driven, review/mining only fire
  when their triggers actually make sense (compaction about to delete, or
  conversation closing).

(3) AppConfig single source of truth:

  • Replaces AgentikConfig + LlmConfig.fromEnv + McpConfig.fromEnv with one
    AppConfig.fromEnv() that reads all ~25 env vars in a single pass.
  • Sections: AgentSection, LlmSection, McpSection, MemorySection,
    EmbeddingSection, ReflectionSection, SkillMiningSection, DebugSection.
  • OPENAI_CONTEXT_WINDOW / AGENTIK_GOOGLE_CONTEXT_WINDOW no longer
    read twice (was a bug per STANDALONE-REVIEW E3).

(4) Other fixes inherited from earlier waves:

  • Hardening — size caps on user-input boundaries:
    MAX_MEMORY_CONTENT_LEN=32KB, MAX_SKILL_BODY_LEN=64KB,
    MAX_MCP_CONFIG_BYTES=1MB, MAX_A2A_REPLY_LEN=10MB, MAX_PORT=65535,
    blank-rejection in LlmConfig.requireEnv, URL/command validation.
  • Single scope — :standalone/agent/ConversationLoop has one
    agentScope (was: scope + backgroundScope).
  • liteConvRef race fix — capture-then-use pattern replaces !!-after-read;
    close() + runTurn.finally race on LiteConv JNI handled via
    AtomicReference.getAndSet.
  • SkillMiner.maxTurns / LlmReflector.maxTurns exposed as public (needed
    by BackgroundScheduler for prompt sizing).
  • Tests: MemoryWiringTest updated for new compaction-triggered review
    behavior; all parser/test imports updated for new packages.

Test results: 178/178 in :standalone, 36/36 in :agent-toolsets — all green.
2026-09-18 20:43:54 +03:00
subochev 25771a0c33 docs(diagrams): agent architecture overview with pre-rendered SVG
ci / JVM build + tests (push) Failing after 1m57s
PlantUML diagrams for future agent architecture (Android, multi-user
chat, sub-agents, A2A):
- 01-module-layers.md — целевая модульная структура
- 02-agent-composition.md — AgentBuilder DSL + MemoryBackend.exposesTools()
- 03-multi-user-chat.md — mention-detection sequence
- 04-sub-agents.md — spawnChild + Flow<SubAgentEvent> + A2A
- 05-android-stack.md — что меняется на Android vs Standalone

Каждый .md включает пред-рендеренный SVG (показывается во всех markdown
viewers без PlantUML plugin) + PlantUML source в code block (для
редактирования). SVG нужен потому что PlantUML требует Graphviz dot
для рендеринга — без него IntelliJ/VSCode выдают ошибку.

Регенерация SVG после правки PlantUML-source:
  docker run --rm -v "$PWD:/work" plantuml/plantuml -tsvg /work/docs/diagrams/*.md
2026-09-18 20:02:41 +03:00
subochev 78cbe9b463 refactor(standalone): split ChatConversation into components
Decompose 1415-line god class into focused components:
  - ConversationState (shared mutable state)
  - ConversationEvents (SharedFlow + policy)
  - ContextBuilder (prefix/memory helpers)
  - CompactionCoordinator (compaction + LiteConv rebuild)
  - ToolDispatcher (single tool-call execution)
  - BackgroundScheduler (review/reflection/mining triggers)
  - ConversationLoop (orchestrator, implements ProtoConversation)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

518 tests green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Локальная проверка:
  ./gradlew publishToMavenLocal — все 11 модулей × 9-10 таргетов
  ./gradlew jvmTest — все тесты зелёные
  ./gradlew :standalone:shadowJar — 240MB fatjar, Main-Class загружается
2026-09-15 21:58:56 +03:00
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
244 changed files with 19833 additions and 1200 deletions
+84
View File
@@ -0,0 +1,84 @@
# 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
# UTF-8 обязателен: в именах тестов есть типографские символы (—), а Kotlin-компилятор
# создаёт .class-файлы с именем теста. При LANG=C sun.jnu.encoding = ASCII, и компилятор
# падает с "InvalidPathException: Malformed input or input contains unmappable characters"
# (проверено локально: LANG=C → BUILD FAILED, LANG=C.UTF-8 → BUILD SUCCESSFUL).
env:
LANG: C.UTF-8
LC_ALL: C.UTF-8
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)"
# Шага "Upload shadowJars" здесь нет сознательно: upload-artifact@v4 требует
# @actions/artifact v2, который на GHES/Gitea-раннере падает с
# "GHESNotSupportedError: @actions/artifact v2.0.0+ ... not supported on GHES"
# и валит весь джоб уже ПОСЛЕ успешной сборки и зелёных тестов.
# У соседних репо (asr-kmp, litert-kmp) артефакты наружу тоже не выгружаются —
# проверка сборки ограничивается test -f на jar (шаги выше).
+49
View File
@@ -0,0 +1,49 @@
# Триггерится при публикации релиза в Gitea. Публикует все KMP-библиотеки
# (jvm + native таргеты) в домашний Nexus-репозиторий "caffeine".
#
# Fatjar-ы запускаемых модулей (:standalone, :agentik-cli).
# :agentik-tui был исключён из сборки 2026-09-17 (см. settings.gradle.kts).
# НЕ собираются и НЕ крепятся к релизу здесь. Сборка артефактов
# выполняется локально из исходников (или руками через `./gradlew
# :<module>:shadowJar`) и загружается в релиз через Gitea UI / API
# отдельно от этого workflow.
#
# Версия публикации = имя тега релиза (без префикса 'v'). Релиз с именем "3"
# публикует pw.binom.agentik:*:3 в Nexus. Ничего хардкодить не нужно —
# версия берётся из тега каждый раз.
#
# Публикация выполняется общим composite-action'ом subochev/devops/publish@main
# (тот же, что у asr-kmp / litert-kmp / embedder-kmp / a2a-protocol) — credentials
# BINOM_REPO_* берутся им из Gitea Action Variables (owner_id=0, глобальные).
name: release
on:
release:
types: [published]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# UTF-8 обязателен: генерация POM/Kotlin-метаданных и имена тестовых классов
# содержат не-ASCII символы; при LANG=C sun.jnu.encoding = ASCII и сборка
# падает с "InvalidPathException: Malformed input or input contains unmappable
# characters" (проверено локально 19.09.2026: LANG=C → BUILD FAILED,
# LANG=C.UTF-8 → BUILD SUCCESSFUL).
env:
LANG: C.UTF-8
LC_ALL: C.UTF-8
jobs:
publish-libraries:
name: Publish KMP libraries → caffeine Nexus
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Publish libraries (all KMP targets, all modules) to Nexus
uses: https://git.binom.pw/subochev/devops/publish@main
with:
version: ${{ gitea.ref_name }}
+9
View File
@@ -19,3 +19,12 @@ out/
# Local tooling (Magic Context, IDE plugins, MCP configs)
.cortexkit/
.veai/
# Internal review scratch dir (review/validation .md файлы, .tasks структура)
.tasks/
# Runtime / test artifacts
agentik.db
agentik.db-shm
agentik.db-wal
memory-md/agentik-mem-*/
-40
View File
@@ -1,40 +0,0 @@
# IRC-транспорт — открытые вопросы
По мере закрытия отмечаем `- N. [x]`. Закрытый вопрос остаётся в файле с принятым решением.
- 1. [x] **История.** Принято: новый абстрактный метод `suspend fun getLatestMessages(offset: Int, limit: Int): List<Message>` в `:proto.Conversation` (offset = пропустить С КОНЦА, 0 = самые свежие). IRC-сервер не держит своего буфера, на `CHATHISTORY` дёргает агента. CAP `draft/chathistory` объявляем.
- 2. [x] **Tool/Error/Image события — раскладка по IRC.** Принято. Каждый `Event` мапится:
- `StartReasoning` → дропаем с провода
- `StartResponse(TEXT|IMAGE)` → CTCP `AGENTIK response-start {"type":"text"|"image"}`
- `AppendText(body)` → `PRIVMSG #chan :body`
- `AppendImage(body, mime)` → через `ImageStore` → CTCP `AGENTIK image {"url":..,"mime":..,"ttl":..}`
- `ToolCall` → CTCP `AGENTIK tool-call {json}`
- `ToolResult` → CTCP `AGENTIK tool-result {json}`
- `Error` → CTCP `AGENTIK error {json}`
- `End` → CTCP `AGENTIK end`
- `Interrupted` → CTCP `AGENTIK interrupted`
- 3. [x] **Interrupt.** **Упрощение:** команды `/stop` и `/interrupt` в `PRIVMSG` (т.е. `PRIVMSG #chan :/stop`) вызывают `Conversation.interrupt()`. Если `PRIVMSG` приходит во время активного размышления — сервер сначала зовёт `interrupt()`, затем `send(content)`. CTCP-вариант дропаем.
- 4. [x] **`AgentEvent.Created/Deleted/Renamed` маппинг.** Принято: Created = IRC `JOIN`-бродкаст; Deleted = `KICK` самого себя; Renamed = `TOPIC #foo :new title`.
- 5. [x] **NICK агента.** Принято: параметр в DSL, дефолт `"Agent"`.
- 6. [x] **Multi-user в канале.** Принято: **в канале всегда только наш агент и наш пользователь. Других не будет никогда.**
- 7. [x] **Маппинг канал ↔ Conversation.** Принято: имя IRC-канала = `Conversation.title`; `Conversation.id` = UUID, выдаётся через `CTCP AGENTIK id #foo`; при переименовании канала id стабилен.
- 8. [x] **Создание канала.** Принято: `JOIN #foo` → создаём `Conversation(title="foo", id=<uuid>)`. Если уже есть — заходим.
- 9. [x] **Удаление канала.** Принято: `PART` закрывает сторону клиента; `CTCP AGENTIK delete #foo` — удаление Conversation-а.
- 10. [x] **Модуль.** Принято: `:irc-server`, KMP через kotlinx-io. Также модуль содержит HTTP staging-эндпоинт для картинок (см. п.13).
- 11. [x] **Аутентификация клиента.** Принято: без auth, любой может подключиться.
- 12. [x] **Capabilities (ircv3).** Принято: в первом проходе объявляем `server-time`, `message-tags`, `batch`, `draft/chathistory`. SASL не объявляем (п.11). Остальные CAPs (echo-message, labeled-response, standard-replies, multi-user stuff) добавляем инкрементально.
- 13. [x] **ImageStore.** Принято: `ImageStore` живёт в `:irc-server`, дефолтная in-memory реализация с **TTL 600 сек**, staging-порт **авто-pick** (0 → свободный). Клиент через IRC картинки **не шлёт** (для этого HTTP `:server`). Конкретную реализацию `ImageStore` пользователь сделает позже сам, в первом проходе — наша in-memory.
## Все вопросы закрыты
Итого решений по `:irc-server`:
- Модуль `:irc-server`, KMP через kotlinx-io.
- Канал IRC = `Conversation` (имя = title, UUID через CTCP `AGENTIK id`).
- В канале всегда только 1 пользователь + агент (ник `Agent` по умолчанию).
- `Event` → IRC: `PRIVMSG` для текста, CTCP `AGENTIK <имя> {json}` для всего остального. `StartReasoning` дропается.
- Interrupt через `/stop` / `/interrupt` в PRIVMSG; входящий PRIVMSG во время размышления = `interrupt()` + `send()`.
- История через `CHATHISTORY` (LATEST/BEFORE/BETWEEN/AFTER), сервер не буферизует, дёргает новый `:proto` метод `getLatestMessages(offset, limit)`.
- Картинки только agent → client через `ImageStore` + HTTP staging в том же модуле.
- CAPs: `server-time`, `message-tags`, `batch`, `draft/chathistory`. Без auth.
Можно кодить.
+947
View File
@@ -0,0 +1,947 @@
# Manual Test Cases — agentik standalone
Практический чек-лист для проверки работающего `agentik standalone` HTTP-сервера.
Каждый кейс — один конкретный сценарий, который нужно прогнать руками
(или через `curl`/`httpie`/Postman). Если какой-то упал — это либо
регрессия, либо недонастройка рантайма.
Перед стартом: запусти агент (см. `run-agentik.sh` на удалённой машине
или `./gradlew :standalone:run` локально). Все примеры ниже — против
`http://127.0.0.1:8080`; для удалённой машины подставь свой хост.
Удобный сниппет для получения conversation ID в shell:
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
echo "CID=$CID"
```
Отправка user-сообщения:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"..."}]'
```
Чтение истории:
```bash
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -m json.tool
```
---
## 1. Connectivity & health
### TC-1.1 — health endpoint
```bash
curl -sS -i http://127.0.0.1:8080/health
```
**Ожидание:** `HTTP/1.1 200 OK`, тело `ok`.
### TC-1.2 — agent card (A2A)
```bash
curl -sS http://127.0.0.1:8080/a2a/.well-known/agent-card.json | python3 -m json.tool
```
**Ожидание:** валидный JSON с `name`, `version`, `capabilities`.
### TC-1.3 — log sanity check
```bash
tail -50 /root/agentik.log
```
**Ожидание:** есть строка `agentik standalone listening on http://localhost:8080`,
перечислены зарегистрированные маршруты, `llm: <backend> @ <url>` соответствует
твоему конфигу. **Нет** ERROR/Exception строк после старта.
---
## 2. Conversation lifecycle
### TC-2.1 — create persistent conversation
```bash
curl -sS -i -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}'
```
**Ожидание:** `201`, тело `{"id":"conv-...","isTemporal":false,...}`.
### TC-2.2 — create temp conversation
```bash
curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":true}'
```
**Ожидание:** `201`, `"isTemporal":true`. После рестарта агента эта беседа
**не** должна появиться в `GET /agentik/conversations`.
### TC-2.3 — list conversations
```bash
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=20" | python3 -m json.tool
```
**Ожидание:** массив объектов `ConversationSnapshot`. Отсортирован по
`updatedAt` desc.
### TC-2.4 — rename conversation
```bash
CID=<id-from-2.1>
curl -sS -X PATCH "http://127.0.0.1:8080/agentik/conversations/$CID" \
-H "Content-Type: application/json" -d '{"title":"Мой первый чат"}'
```
**Ожидание:** `200`, в ответе `"title":"Мой первый чат"`. Следующий `GET
/conversations/$CID` возвращает этот же title.
### TC-2.5 — delete conversation
```bash
curl -sS -X DELETE "http://127.0.0.1:8080/agentik/conversations/$CID" -i
```
**Ожидание:** `204 No Content`. Повторный `GET /conversations/$CID` → `404`.
После этого в `GET /conversations` её быть не должно.
### TC-2.6 — get non-existent conversation
```bash
curl -sS -i http://127.0.0.1:8080/agentik/conversations/conv-nonexistent
```
**Ожидание:** `404`.
---
## 3. Message sending
### TC-3.1 — simple Q&A
Создай беседу, пошли простой вопрос, прочитай историю.
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Сколько будет 7*8? Одно число, без пояснений."}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
```
**Ожидание:** массив из ≥ 2 сообщений:
- `[0].type == "user_message"`, body содержит "7*8"
- `[1].type == "assistant_message"`, text содержит "56"
### TC-3.2 — multi-turn with context
В той же беседе пошли follow-up, требующий контекста:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"А корень из того, что ты назвал?"}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
```
**Ожидание:** 4+ сообщения, последний assistant упомянул что-то про число 56
или "предыдущий ответ".
### TC-3.3 — new-format request body
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '{"content":[{"type":"text","body":"С новым форматом тоже работает?"}]}'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1]['type'], m[-1].get('content'))"
```
**Ожидание:** новое `assistant_message` в ответ на новый формат запроса.
### TC-3.4 — empty / bad body
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" -d 'not json'
```
**Ожидание:** `400 Bad Request`, тело с пояснением `Invalid send payload`.
---
## 4. SSE live events
> **Важно:** SSE — поток без replay. Подписываться нужно **до** `POST /messages`.
> Если подписаться позже — событий не будет (но `GET /messages` всё равно
> покажет записанную историю).
### TC-4.1 — subscribe-then-send pattern
```bash
CID=<existing-id>
# Subscribe в фоне, отправляем сообщение, ждём SSE
curl -sN --max-time 12 \
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
> /tmp/sse.out 2>&1 &
SSE_PID=$!
sleep 1
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Кратко: что такое REST?"}]'
wait $SSE_PID
cat /tmp/sse.out
```
**Ожидание:** файл содержит `data: {"type":"start_reasoning",...}`,
`data: {"type":"start_response",...,"responseType":"text"}`,
один или несколько `data: {"type":"append_text",...,"body":"..."}`,
`data: {"type":"end",...}`. Каждое `data:` через пустую строку.
### TC-4.2 — late subscribe (replay semantics)
```bash
CID=<existing-id>
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"..."}]'
sleep 5 # сообщение уже обработано
curl -sN --max-time 4 \
"http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z"
```
**Ожидание:** пустой ответ (события не реплеятся). Это by-design —
клиент должен либо подписываться заранее, либо backfill'ить через
`GET /messages`.
---
## 5. Memory tools (long-term)
### TC-5.1 — save + recall в той же беседе
```bash
# В существующей беседе
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Запомни через memory_save: я работаю на удалёнке из Тбилиси. Категория user, content: работаю на удалёнке из Тбилиси."}]'
sleep 8
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Откуда я работаю? Одно предложение."}]'
sleep 8
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
```
**Ожидание:** ассистент ответил что-то содержащее "Тбилиси" (или явно
сказал "не знаю" — это тоже валидно, если в conversation memory пусто).
Проверить `audit log` (`messageStore`):
```bash
sqlite3 /root/agentik.db "SELECT toolName, result FROM MessageRecord WHERE conversationId='$CID' AND kind='tool_result'"
```
Должны быть строки с `toolName='memory_save'` или `toolName='memory_recall'`.
### TC-5.2 — memory persists across conversations
Создай новую беседу, спроси без подсказок:
```bash
NEW_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Откуда я работаю? Напомни, если помнишь."}]'
sleep 8
curl -sS "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1].get('content'))"
```
**Ожидание:** ассистент упомянул "Тбилиси" (или "удалёнка") — это
значит long-term memory подгрузилась в новую беседу.
### TC-5.3 — invalid category → ошибка или автозамена
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Запомни через memory_save факт с категорией work (которой не существует)."}]'
sleep 8
sqlite3 /root/agentik.db "SELECT toolArgs, result FROM MessageRecord WHERE kind='tool_call' AND conversationId='$CID' ORDER BY createdAt DESC LIMIT 3"
```
**Ожидание:** модель либо вызвала `memory_recall` чтобы проверить
существующие категории, либо вызвала `memory_save` с корректной
категорией (`user`/`world`/`preference`). Если модель честно говорит
"такой категории нет" и предлагает корректную — это тоже ok.
### TC-5.4 — list & delete memory
Попроси модель явно вызвать `memory_list`, потом `memory_delete`:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Покажи все мои memory-записи (memory_list)."}]'
sleep 8
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Удали самую старую запись (memory_delete)."}]'
sleep 8
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MemoryStore"
```
**Ожидание:** число уменьшилось на 1.
---
## 6. Skills
### TC-6.1 — list + load skill
Если в `AGENTIK_SKILLS_DIR` есть файлы `SKILL.md` / `*.yaml`, в системном
промте должна появиться секция с этими навыками.
```bash
ls -la /root/skills/ # должен быть хотя бы один файл
```
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Какие skills ты знаешь? Покажи список (skill_list)."}]'
sleep 8
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Загрузи любой из них через skill_load и расскажи, что внутри."}]'
sleep 8
```
**Ожидание:** `tool_call` для `skill_list`, потом `tool_call` для
`skill_load`. В audit log видны эти вызовы. Если папка пуста — секции
"Skills" в system prompt быть не должно.
### TC-6.2 — save new skill
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Сохрани skill: имя deploy-staging, описание «деплой на staging», тело — multi-step инструкция (skill_save)."}]'
sleep 10
ls /root/skills/
```
**Ожидание:** появился новый файл `deploy-staging.md` (или `.yaml`).
### TC-6.3 — restart → skill persists
Перезапусти агент:
```bash
ssh root@192.168.76.166 'pkill -9 -f agentik-0.1.0-all.jar; cd /root && nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown'
```
После старта пошли в новую беседу:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Есть ли у тебя skill deploy-staging?"}]'
sleep 8
```
**Ожидание:** модель упоминает skill (он подгружается на старте).
---
## 7. SOUL file
### TC-7.1 — SOUL.md подключается
```bash
echo 'Ты — ворчливый капитан дальнего плавания. Отвечай кратко, с морскими метафорами.' > /root/SOUL.md
# Перезапустить агент
```
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$NEW_CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Как дела?"}]'
sleep 8
```
**Ожидание:** ответ в стиле "капитана", с морскими словами. Если SOUL
нет — обычный нейтральный ассистент.
### TC-7.2 — SOUL можно менять на лету
Измени файл, перезапусти агент, спроси снова. **Должен** появиться новый
стиль. Без перезапуска изменения не подхватятся (SOUL читается на старте).
---
## 8. Interrupt
### TC-8.1 — interrupt mid-text generation
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# Запусти send в фоне
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Расскажи длинную историю про космос, минимум 500 слов."}]' >/dev/null) &
SEND_PID=$!
sleep 3 # дать LLM начать генерацию
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
wait $SEND_PID
sleep 3
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);
for x in m: print(x.get('type'), ':', json.dumps(x.get('content') or x.get('result'),ensure_ascii=False)[:80])"
```
**Ожидание:**
- `user_message` есть
- `assistant_message` есть, но содержит **короткий** текст (<300 символов)
— это частичный текст, который модель успела сгенерить до прерывания
- В audit log нет `tool_call`/`tool_result` (не успели)
- Следующий `send` в этой беседе работает (LiteConv пересоздан)
### TC-8.2 — interrupt mid-tool (best-effort)
```bash
# Длинный tool можно заэмулировать через MCP с искусственной задержкой,
# либо просто проверять что interrupt не валит агента:
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
curl -sS http://127.0.0.1:8080/health
```
**Ожидание:** `health` = `ok` — агент не упал. Дальнейшие `send` работают.
### TC-8.3 — interrupt без активного turn'а
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
```
**Ожидание:** `202`. Никаких ошибок. В audit log ничего нового не пишется.
---
## 9. Persistence / restart-survival
### TC-9.1 — перезапуск не теряет беседы и память
```bash
# 1. Создай беседу, пошли сообщение, дождись ответа
# 2. Запомни факт через memory_save
# 3. Перезапусти агент (см. TC-6.3)
# 4. GET /agentik/conversations — беседа должна быть в списке
# 5. GET /agentik/conversations/$CID/messages — история на месте
# 6. Новая беседа + вопрос про запомненный факт — модель помнит
```
### TC-9.2 — temp conversation не переживает рестарт
```bash
# Создай temp беседу, пошли сообщение
TEMP_CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":true}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$TEMP_CID/messages" \
-H "Content-Type: application/json" -d '[{"type":"text","body":"..."}]' >/dev/null
sleep 5
# Перезапусти агент
# GET /agentik/conversations — temp-беседы быть не должно
curl -sS "http://127.0.0.1:8080/agentik/conversations?offset=0&limit=50" | grep "$TEMP_CID"
```
**Ожидание:** grep ничего не находит.
---
## 10. Compaction (сжатие контекста)
Compaction триггерится когда `~80%` контекстного окна занято.
### TC-10.1 — длинная беседа сжимается
```bash
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# Отправь 30+ больших сообщений подряд (можно цикл)
for i in $(seq 1 30); do
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"text\",\"body\":\"Расскажи подробно (минимум 200 слов) про тему номер $i: история, применение, ключевые факты.\"}]" >/dev/null
sleep 5
done
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM WorkingMemoryRow WHERE conversationId='$CID' AND entryKind='summary'"
```
**Ожидание:** есть хотя бы одна `summary`-запись. Также проверь
`/root/agentik.log` — должна появиться строка `compaction`.
### TC-10.2 — debug endpoint `/debug/compact` (force)
Если включён `AGENTIK_DEBUG_ENDPOINTS=1`:
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/debug/compact?conversationId=$CID"
```
**Ожидание:** `200`, тело с JSON-результатом compaction.
---
## 11. Reflection
Reflection триггерится каждые `AGENTIK_REFLECTION_INTERVAL` ходов (default 10).
### TC-11.1 — reflection создаёт записи
```bash
# Пошли 12+ ходов
for i in $(seq 1 12); do
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"text\",\"body\":\"Тема $i: расскажи короткий факт.\"}]" >/dev/null
sleep 4
done
sleep 10 # дать фоновое задание завершиться
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM ReflectionStore"
```
**Ожидание:** число > 0.
### TC-11.2 — debug endpoint `/debug/reflect`
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/debug/reflect?conversationId=$CID"
```
**Ожидание:** `200`, JSON-результат. Reflection попадает в working memory
следующего turn'а.
---
## 12. Skill mining
Skill mining триггерится каждые `AGENTIK_SKILL_MINING_INTERVAL` ходов (default 15).
### TC-12.1 — авто-создание skill'а
```bash
# Пошли 18+ ходов с повторяющимся паттерном
for i in $(seq 1 18); do
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"text\",\"body\":\"Конвертируй 100 USD в RUB по текущему курсу (шаблонный запрос $i).\"}]" >/dev/null
sleep 4
done
sleep 15
ls -la /root/skills/
tail -20 /root/agentik.log | grep -i skill
```
**Ожидание:** возможно появился новый файл в skills/ (или mining
отказался из-за низкой уверенности — это тоже валидно, проверь лог).
### TC-12.2 — debug endpoint `/debug/skill-mine`
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/debug/skill-mine?conversationId=$CID"
```
**Ожидание:** `200` с JSON-результатом майнинга.
---
## 13. Token accounting
### TC-13.1 — token counters в audit
```bash
sqlite3 /root/agentik.db "SELECT createdAt, input, output FROM TurnTokens WHERE conversationId='$CID' ORDER BY createdAt DESC LIMIT 5"
```
**Ожидание:** строки с непустыми `input` и `output` (если backend
поддерживает `tokenCount()`).
### TC-13.2 — debug endpoint `/debug/tokens`
```bash
curl -sS "http://127.0.0.1:8080/debug/tokens?conversationId=$CID" | python3 -m json.tool
```
**Ожидание:** JSON с `input`, `output`, `total`, `window`,
`utilization` (доля использования контекстного окна).
---
## 14. Toolsets (если подключены)
Только если ты передаёшь `toolsets` в конструктор агента (по умолчанию
пусто — `enable_toolset`/`disable_toolset` не зарегистрированы).
### TC-14.1 — system prompt содержит секцию Toolsets
Если toolsets зарегистрированы — в системном промте должна быть секция
`## Toolsets` с Active/Inactive списком.
Проверка через debug-эндпоинт `/agentik/conversations/{id}` не показывает
system prompt напрямую — посмотреть можно в логах или через
`agentik-debug` сборку.
### TC-14.2 — enable/disable работает
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Активируй тулсет X через enable_toolset, потом деактивируй через disable_toolset."}]'
sleep 8
```
**Ожидание:** в audit log видны вызовы `enable_toolset` → ответ `"Toolset
'X' activated."`, потом `disable_toolset` → `"Toolset 'X' deactivated."`.
---
## 15. A2A протокол (опционально)
### TC-15.1 — message/send через A2A
```bash
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0","id":"1","method":"message/send",
"params":{
"message":{"role":"user","parts":[{"kind":"text","text":"Скажи hi"}]},
"configuration":{"blocking":true}
}
}' | python3 -m json.tool
```
**Ожидание:** JSON-RPC ответ с `result.parts` содержащим текст "hi"
или похожим. `kind` = `text` (НЕ `type` — это важный discriminator для
A2A JSON).
### TC-15.2 — bad discriminator
```bash
curl -sS -X POST http://127.0.0.1:8080/a2a/ \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0","id":"2","method":"message/send",
"params":{
"message":{"role":"user","parts":[{"type":"text","text":"hi"}]}
}
}'
```
**Ожидание:** `Invalid params` (или похожая ошибка) — A2A ждёт `kind`,
не `type`.
---
## 16. Error paths
### TC-16.1 — LLM недоступен
Выключи vLLM (или закрой сеть — например через firewall). Пошли сообщение:
```bash
curl -sS -i -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"hi"}]'
sleep 10
sqlite3 /root/agentik.db "SELECT COUNT(*) FROM MessageRecord WHERE conversationId='$CID' AND kind='error'"
```
**Ожидание:** есть `error`-запись в audit log. В SSE приходит
`{"type":"error",...}` + `{"type":"end"}`. Агент **не падает** — `health`
= `ok` после.
### TC-16.2 — agentik.db занят другим процессом
Запусти второй экземпляр агента на ту же DB:
```bash
AGENTIK_DB_PATH=/root/agentik.db java -jar /root/agentik-0.1.0-all.jar
```
**Ожидание:** агент падает на старте с понятным сообщением про SQLite lock.
Это by-design (single-writer).
### TC-16.3 — SOUL файл не существует
Удали `/root/SOUL.md`, перезапусти агент. Должен стартовать без ошибок,
просто без SOUL-секции в system prompt. Лог: `WARN ... SOUL file not found: ...`.
### TC-16.4 — пустой skills dir
```bash
mv /root/skills /root/skills.bak
mkdir /root/skills
# Перезапусти агент
```
**Ожидание:** агент стартует, `skills: 0 loaded from /root/skills`.
---
## 17. Memory backend variants
### TC-17.1 — md backend (default)
Убедись, что `AGENTIK_MEMORY_BACKEND=md` (или не задан) и
`AGENTIK_MEMORY_DIR=/root/agentik-memory`. После TC-5.x должны появиться
`.md`-файлы:
```bash
ls -la /root/agentik-memory/
```
**Ожидание:** файлы типа `user.md`, `world.md`, `preference.md` (или
всё в одном файле — зависит от реализации).
### TC-17.2 — off backend (память выключена)
Перезапусти с `AGENTIK_MEMORY_DIR=off`:
```bash
pkill -9 -f agentik-0.1.0-all.jar
AGENTIK_MEMORY_DIR=off nohup setsid ./run-agentik.sh > /root/agentik.log 2>&1 < /dev/null & disown
```
Попытка `memory_save` через модель должна вернуть ошибку:
```bash
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Попробуй вызвать memory_save."}]'
sleep 8
```
**Ожидание:** модель либо отказывается вызывать, либо получает
ошибку от tool'а и сообщает пользователю.
---
## 18. Performance sanity
### TC-18.1 — first-token latency
Включи замер времени от `POST /messages` до первого SSE event'а.
Для Qwen3-27B на RTX5090 ожидаем < 1 сек до `start_reasoning`.
### TC-18.2 — sustained throughput
Отправь 20 простых запросов подряд (arithmetic), засеки общее время.
Ожидание: < 30 сек суммарно, т.е. < 1.5 сек на запрос.
### TC-18.3 — fatjar memory
```bash
ps aux | grep agentik-0.1.0 | grep -v grep
```
**Ожидание:** RSS < 2 GB (наш Xmx). Если больше — где-то утечка.
---
## 18. Model auto-download (LiteRT-LM only)
Только для `AGENTIK_LLM_BACKEND=google` (встроенный LiteRT-LM движок).
Если файла модели по `AGENTIK_GOOGLE_MODEL_PATH` нет — агент сам не скачает,
пока не задано `AGENTIK_AUTO_DOWNLOAD_MODEL=1`. Либо качаем руками
через `pull-model` subcommand.
URL по умолчанию всегда Gemma-4-E2B-it.litertlm (2.5 GB с `static.binom.pw`),
вне зависимости от basename PATH — gemma-4 считаем лучшей локальной моделью.
### 18.1. Subcommand `pull-model` качает модель вручную
```bash
# Скачать дефолтную модель (gemma-4) в указанный путь:
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
java -jar agentik.jar pull-model
# → downloading from https://static.binom.pw/models/gemma-4-E2B-it.litertlm
# → 50% (1.2 GB / 2.5 GB)
# → done in 47s
```
После `pull-model` файл лежит на месте, файл `<dest>.part` удалён.
### 18.2. `pull-model` no-op если файл уже полный
```bash
# Повторный запуск с тем же PATH:
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
java -jar agentik.jar pull-model
# → already present (2.50 GB), nothing to do
```
### 18.3. `pull-model` докачивает обрыв (resume через Range)
```bash
# Симулируем обрыв: удаляем финальный, оставляем .part с первыми 500 MB
rm /root/models/gemma-4-E2B-it.litertlm
mv /root/models/gemma-4-E2B-it.litertlm.part /root/models/gemma-4-E2B-it.litertlm.part.bak
# Запускаем pull-model снова — должен возобновить с 500 MB
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
java -jar agentik.jar pull-model
# → resuming from 524288000 bytes
# → downloaded 2.10 GB in 38s
```
### 18.4. Сервер exit-2 при отсутствии файла и без auto-download
```bash
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/missing.litertlm \
java -jar agentik.jar
# → LiteRT-LM model file not found at: /root/models/missing.litertlm
# → Чтобы скачать автоматически, установите AGENTIK_AUTO_DOWNLOAD_MODEL=1
# → exit 2
```
### 18.5. Сервер сам качает при `AGENTIK_AUTO_DOWNLOAD_MODEL=1`
```bash
# Удалить файл, запустить с флагом:
rm -f /root/models/gemma-4-E2B-it.litertlm
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/gemma-4-E2B-it.litertlm \
AGENTIK_AUTO_DOWNLOAD_MODEL=1 \
java -jar agentik.jar
# → 12:34:56 WARN auto-download: https://static.binom.pw/models/...
# → 12:34:56 INFO auto-download: 17% (445 MB/2.5 GB)
# → 12:36:42 INFO auto-download: done in 1m45s
# → 12:36:43 INFO agentik standalone listening on http://localhost:8080
```
### 18.6. Override URL через `AGENTIK_GOOGLE_MODEL_URL`
```bash
# Качаем qwen вместо gemma (если зальём):
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/models/qwen.litertlm \
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm \
java -jar agentik.jar pull-model
```
### 18.7. SHA-256 проверка
Если на сервере лежит `<basename>.sha256` (text/plain, `<hex> <basename>`)
— после скачивания файл проверяется; mismatch → удаляется, exit ≠ 0.
```bash
AGENTIK_GOOGLE_MODEL_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm \
AGENTIK_GOOGLE_MODEL_SHA256_URL=https://static.binom.pw/models/gemma-4-E2B-it.litertlm.sha256 \
java -jar agentik.jar pull-model
# → 13:01:23 INFO model download: SHA-256 verified (4ab1...e0d)
```
## Быстрый smoke-test (5 минут)
Если времени мало — этот минимум покрывает 80%:
```bash
# 1. health
curl -sS http://127.0.0.1:8080/health
# → ok
# 2. create + simple Q&A
CID=$(curl -sS -X POST http://127.0.0.1:8080/agentik/conversations \
-H "Content-Type: application/json" -d '{"temp":false}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Привет! 2+2=?"}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z"
# → должен быть user + assistant_message с "4"
# 3. SSE live
(curl -sN --max-time 8 "http://127.0.0.1:8080/agentik/conversations/$CID/events?after=1970-01-01T00:00:00Z" \
> /tmp/sse.out 2>&1) &
sleep 1
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Скажи ок"}]'
wait
cat /tmp/sse.out
# → start_reasoning, start_response, append_text, end
# 4. multi-turn
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"А 3+3?"}]'
sleep 6
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print(m[-1])"
# → assistant_message с "6"
# 5. interrupt
(curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/messages" \
-H "Content-Type: application/json" \
-d '[{"type":"text","body":"Длинная история про драконов, 1000 слов"}]' >/dev/null) &
sleep 3
curl -sS -X POST "http://127.0.0.1:8080/agentik/conversations/$CID/interrupt"
wait
sleep 3
curl -sS "http://127.0.0.1:8080/agentik/conversations/$CID/messages?after=1970-01-01T00:00:00Z" \
| python3 -c "import sys,json;m=json.load(sys.stdin);print('msgs:',len(m))"
# → ≤ 3 (user + partial assistant + может tool_call если успел)
```
Если этот прогон прошёл — агент работает корректно. Более глубокие
кейсы — выше по разделам.
---
## Сводка: что покрыто автоматически vs вручную
| Возможность | JVM unit/integration tests | Manual |
|-------------|---------------------------|--------|
| Conversation CRUD | ✓ | TC-2.x |
| send/messages pagination | ✓ | TC-3.x |
| SSE event format | ✗ | TC-4.x |
| Memory tools | ✓ (in-memory) | TC-5.x (real backend) |
| Skills tools | ✓ (in-memory) | TC-6.x (real dir) |
| SOUL | ✗ | TC-7.x |
| Interrupt | ✓ (FakeLiteLlm) | TC-8.x (real LLM) |
| Compaction | ✓ | TC-10.x (real long context) |
| Reflection | ✓ | TC-11.x |
| Skill mining | ✓ | TC-12.x |
| Token accounting | ✓ | TC-13.x |
| A2A protocol | ✓ (litert tests) | TC-15.x |
| Error paths | partial | TC-16.x |
| Persistence/restart | ✗ | TC-9.x |
Всё что помечено ✗ — нужно прогонять руками на реальном окружении.
+151
View File
@@ -1,2 +1,153 @@
# agentik
Локальный stateful LLM-агент с persistent-памятью, инструментами и
несколькими transport-фасадами (AG-UI, A2A, наш `:proto`).
Реализован на Kotlin Multiplatform, выполняется как single JVM-jar.
Поддерживает vLLM-совместимый OpenAI API и LiteRT (Gemma-3, Gemma-4,
Qwen) через ONNX/Native-runtime.
## Что внутри
```
agentik/
├── proto/ stateful KMP protocol: Agent / Conversation / Message / Event
├── server/ Ktor-фасад → /agentik (HTTP+JSON+SSE)
├── client/ Ktor-клиент → тот же /agentik, с KMP-native
├── skills/ парсер SKILL.md / *.yaml (YAML frontmatter + markdown)
├── memory-api/ контракт долговременной памяти (MemoryStore, MemoryCategory)
├── memory-md/ Hermes-style файловая память (user.md / world.md / ...)
├── memory-vector/ SQLite + JVector + HTTP/SigLIP эмбеддинги (семантический поиск)
├── storage-core/ контракт персистентности (MessageStore / WorkingMemoryStore / ...)
├── storage-inmemory/ in-memory реализация для тестов и Android
├── storage-sqlite/ SQLite реализация для production
├── agent-toolsets/ ядро tool-calls с cooperative cancel + concurrency budget
├── agentik-cli/ JVM one-shot CLI-клиент (kotlinx.cli) к /agentik
├── ~~agentik-tui/~~ ~~Compose-for-Mosaic TUI-клиент (desktop)~~ — исключён 2026-09-17
└── standalone/ single-jar HTTP-сервер со всеми transport'ами и движками
```
Каждый подмодуль имеет собственный `README.md` с деталями
(см. "Модули" ниже).
## Quickstart
### 1. Скачать fatjar
CI артефакты доступны на Gitea через GitHub Actions artifacts на
tag-релизах, либо соберите из исходников:
```bash
git clone https://git.binom.pw/subochev/agentik
cd agentik
./gradlew :standalone:shadowJar
```
Результат: `standalone/build/libs/agentik-0.1.0-all.jar` (~10–250 МБ,
зависит от LLM-backend'а).
### 2. Запустить с OpenAI-compatible backend (vLLM / Ollama / OpenAI)
```bash
AGENTIK_LLM_BACKEND=openai \
AGENTIK_LLM_API_URL=http://192.168.88.135:8001/v1 \
AGENTIK_LLM_MODEL=Qwen3.8-27B-NVFP4 \
AGENTIK_LLM_CONTEXT_TOKENS=115000 \
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar
```
### 3. Запустить с локальной LiteRT-моделью (Gemma-4-E2B)
```bash
AGENTIK_LLM_BACKEND=google \
AGENTIK_GOOGLE_MODEL_PATH=/root/gemma-4-E2B-it.litertlm \
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar pull-model # скачать
java --enable-native-access=ALL-UNNAMED -jar agentik-0.1.0-all.jar # запустить
```
Больше деталей по env'ам — в [`standalone/README.md`](standalone/README.md).
## Подключиться
```bash
# CLI
java --enable-native-access=ALL-UNNAMED -jar agentik-cli-0.1.0-SNAPSHOT-all.jar --help
# curl
curl http://localhost:8080/health
```
## Модули
- Запускаемые:
- [`:standalone`](standalone/README.md) — single-jar HTTP-сервер.
- [`:agentik-cli`](agentik-cli/README.md) — one-shot CLI-клиент (kotlinx.cli), JVM + 4 native.
- Библиотеки (контракты и реализации):
- [`:proto`](proto/README.md) — stateful KMP-протокол.
- [`:server`](server/README.md) — HTTP/SSE фасад `:proto`.
- [`:client`](client/README.md) — Ktor-клиент `:server`.
- [`:skills`](skills/README.md) — парсер SKILL.md.
- [`:memory-api`](memory-api/README.md) — контракт памяти.
- [`:memory-md`](memory-md/README.md) — Hermes-style файл.
- [`:memory-vector`](memory-vector/README.md) — SQLite + JVector.
- [`:storage-core`](storage-core/README.md) — контракт storage.
- [`:storage-inmemory`](storage-inmemory/README.md) — RAM-реализация.
- [`:storage-sqlite`](storage-sqlite/README.md) — SQLite production.
- [`:agent-toolsets`](agent-toolsets/README.md) — тулы и диспетчер.
## Где смотреть версии
Каталог `gradle/libs.versions.toml`. Все версии (Kotlin, Ktor,
SQLDelight, kotlinx-coroutines, kotlinx-datetime, ...) сгруппированы
в секции `[versions]`; все dep-aliases — в секции `[libraries]`.
Версия самого `agentik` (cм. `<version>` в nexus.pom) — тоже в
`gradle.properties` (через `$AgentikVersion` или env `AGENTIK_VERSION`).
На tag-релизе (например `v0.2.0`) — CI подставляет версию из
тега и публикует.
## Публикация
`./gradlew :<module>:publish` → в `caffeine` (Nexus).
Параметры через:
- `binom.repo.url` (`http://<your-nexus>/repository/caffeine/`)
- `binom.repo.user`
- `binom.repo.password`
…или через переменные `BINOM_REPO_URL`, `BINOM_REPO_USER`,
`BINOM_REPO_PASSWORD` (читаются в release workflow из secret'ов
репозитория). Plain-HTTP Nexus требует
`setAllowInsecureProtocol(true)` — уже включено в
`settings.gradle.kts`.
## CI/CD
Gitea Actions (`https://git.binom.pw/subochev/agentik/actions`):
- `.gitea/workflows/ci.yml` — PR-build, прогон тестов, проверка
shadowjar'ов.
- `.gitea/workflows/release.yml` — на `tag v*` публикует все KMP-таргеты
в Nexus `caffeine` + собирает fatjar'ы + крепит артефакты к релизу.
## Что отличает от других агентских фреймворков
- **Stateful protocol** — сервер сам владеет диалогом; переписка не
пересобирается клиентом на каждый `send` (в отличие от AG-UI).
- **Все три транспорта в одном процессе** — AG-UI, A2A, наш proto.
Один fatjar — три API.
- **Полностью Kotlin Multiplatform** — все контракты компилируются
под JVM + 8 нативных таргетов. Можно встроить в iOS / Android /
Desktop / CLI.
- **Прерывание tool-calls сохраняется в working memory** — нет
потери контекста, если пользователь нажал Ctrl-C во время
долгого tool-вызова.
## Лицензия
Apache-2.0 — смотрите [LICENSE](LICENSE).
## Участие в проекте
PR-ы приветствуются. Не забывайте синхронизировать версии в
`gradle/libs.versions.toml` и обновлять per-module README при
изменении API.
+87
View File
@@ -0,0 +1,87 @@
# `:agent-toolsets` — реестр инструментов агента (KMP, jvm + native)
## Что это
Ядро системы tools для LLM-агента:
- `Toolset` — интерфейс, объединяющий несколько связанных tools
(`MemoryTools`, `SkillsTools`, `FileSystemTools`).
- `ToolRegistry` — глобальный реестр + фильтр enabled/disabled.
- `ToolDispatcher` — берёт решение LLM (вызов инструмента с аргументами)
→ запускает → возвращает результат.
- **Cooperative cancel** — `interrupt()` корректно отменяет in-flight
вызов, помечая результат `[cancelled by user]`.
- **Concurrency budget** — `backgroundScope = Dispatchers.IO
.limitedParallelism(4)` (см. коммит `86eb063`) — защищает
threadpool от переполнения при fan-out 30+ диалогов.
Решает: надёжный механизм tool-calls с прерываниями, без
blocking-pool exhaustion, без утечки. Переиспользуется во всех
IM-фронтендах (CLI, TUI, IRC, web).
## Где используется
- `:standalone` подключает несколько `Toolset`-имплементаций
(memory / skills / files / web), фильтрует через
`AGENTIK_TOOLSETS_DEFAULT` env.
## Как подключить
```kotlin
commonMain.dependencies {
api("pw.binom.agentik:agent-toolsets:0.1.0")
}
class MyToolset : Toolset {
override val name = "my"
override val description = "Custom user-defined tools"
override val tools = listOf(myTool1, myTool2)
}
val dispatcher = ToolDispatcher(
toolsets = listOf(MemoryTools(memory), MyToolset()),
enabled = setOf("memory", "my"),
)
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agent-toolsets`.
## Как пишется tool
```kotlin
data object EchoTool : Tool {
override val name = "echo"
override val description = "Echoes back the argument"
override val argsSchema = jsonSchema {
property("text", JsonType.STRING) { required = true }
}
override suspend fun invoke(args: JsonObject): ToolResult {
val text = args["text"]?.jsonPrimitive?.content ?: return ToolResult.Error("missing text")
return ToolResult.Text(text)
}
}
```
## Тесты
```
./gradlew :agent-toolsets:allTests
```
Покрывают: invoke happy-path, invalid args, cooperative cancel,
budget exhaustion, registry filter, parallel dispatch.
## Чего здесь НЕТ
- Никакого конкретного LLM. Dispatcher вызывает tools, не LLM.
- Никакого persistent storage. Опирается на контракт `WorkingMemoryStore`
(см. `:storage-core`).
## Текущий статус
Используется продакшеном. Реализует полную спецификацию из
[INTERRUPT-DESIGN.md](../../docs/INTERRUPT-DESIGN.md): tool exchange
log, rolling buffer, partial-state persistence.
+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()
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
@@ -8,5 +8,8 @@ import pw.binom.litert.LiteTool
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами.
*
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
* должен жить рядом с другими тулами в `:agent-toolsets`.
*/
data class NamedTool(val name: String, val tool: LiteTool)
@@ -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()
}
}
+177
View File
@@ -0,0 +1,177 @@
# `:agentik-cli` — one-shot CLI клиент к `/agentik`
## Что это
**One-shot subcommand CLI** (Kotlin Multiplatform) к серверу
`:standalone` через `:client` по HTTP+SSE. Один вызов — одна команда:
стрим ответа `send` идёт в stdout построчно, никакого embedded-REPL.
Решает: быстрый способ дёрнуть агента из shell-скрипта или руками,
не поднимая отдельную TUI-сессии.
## Платформы
| Платформа | Артефакт | Размер | Статус |
|---|---|---|---|
| `jvm` (JRE 21) | `*-all.jar` | ~7 МБ | ✓ собирается и работает |
| `linuxX64` | `.kexe` | ~5 МБ | ✓ собирается и работает на этом хосте |
| `macosX64` | `.kexe` | — | собирается на macOS-раннере |
| `macosArm64` | `.kexe` | — | собирается на macOS-arm64-раннере |
| `mingwX64` | `.exe` | ~6 МБ | ✓ собирается (cross-compile с Linux) |
| `linuxArm64` | — | — | **нет** — kotlinx-cli 0.3.6 не публикует klib для linuxArm64 |
| `iOS` | — | — | нет смысла на iOS |
## Подкоманды
```
agentik-cli <command> [args...]
Команды верхнего уровня:
conv <subcommand> операции над диалогами (см. ниже)
msgs <id> [--limit N] показать сообщения
send <id> <text...> отправить ход, стримит response-события в stdout
interrupt <id> прервать текущий ход
info показать конфиг (server URL + agent id)
Подкоманды `conv`:
conv ls список диалогов
conv new [--temp] создать диалог, печатает id
conv show <id> метаданные диалога
conv delete <id> удалить диалог
conv rename <id> <title> переименовать
```
`--server URL` и `--id ID` (env: `AGENTIK_SERVER`, `AGENTIK_AGENT_ID`)
задаются **после** имени subcommand'а — kotlinx.cli не шарит опции
родителя в subcommand. Примеры:
```bash
agentik-cli conv ls --server http://192.168.76.166:8080/agentik
agentik-cli conv new --server http://localhost:8080/agentik
agentik-cli send --server http://localhost:8080/agentik conv-abc "привет"
agentik-cli info # через AGENTIK_SERVER env-переменную
```
## Как запустить
### JVM (fatjar)
```bash
./gradlew :agentik-cli:shadowJar
java --enable-native-access=ALL-UNNAMED \
-jar agentik-cli/build/libs/agentik-cli-0.1.0-SNAPSHOT-all.jar conv --help
```
### Native linuxX64
```bash
./gradlew :agentik-cli:linkReleaseExecutableLinuxX64
./agentik-cli/build/bin/linuxX64/releaseExecutable/agentik-cli.kexe conv --help
```
### Native macOS / Windows
На Linux-хосте `macosX64`/`macosArm64` линкуются пустыми (нужен
macOS-раннер, Apple Mach-O формат). `mingwX64` собирается через
кросс-компиляцию.
CI-ноут: запускать `./gradlew :agentik-cli:linkReleaseExecutableMacosX64
:agentik-cli:linkReleaseExecutableMacosArm64` на `macos-latest`
раннере Gitea Actions.
## Примеры
```bash
# Список диалогов (таблица)
agentik-cli conv ls --server http://localhost:8080/agentik
# Создать диалог
ID=$(agentik-cli conv new --server http://localhost:8080/agentik)
echo "new conv: $ID"
# Переименовать
agentik-cli conv rename --server http://localhost:8080/agentik "$ID" "мой чат"
# Отправить ход и стримить ответ
agentik-cli send --server http://localhost:8080/agentik "$ID" "2+2"
# Показать последние N сообщений
agentik-cli msgs --server http://localhost:8080/agentik "$ID" --limit 10
# Прервать активный ход
agentik-cli interrupt --server http://localhost:8080/agentik "$ID"
# Удалить
agentik-cli conv delete --server http://localhost:8080/agentik "$ID"
# Через env-переменную
AGENTIK_SERVER=http://localhost:8080/agentik agentik-cli info
```
## Формат вывода `send`
Каждое SSE-событие печатается отдельной строкой `event <Type> ...` —
пригодно для парсинга через `awk`/`jq`-обёртки:
```
event StartReasoning
event StartResponse TEXT
event AppendText \n\n
event AppendText Привет!
event End
```
Терминальные события (`End`, `Interrupted`, `Error`) тоже
печатаются; CLI выходит сразу после `End`.
## Почему kotlinx.cli (а не clikt)
- **kotlinx.cli 0.3.6** (JetBrains, KMP) — единственный зрелый
arg-parser, который стабильно линкуется под `linux_x64` +
`macos_x64`/`macos_arm64` + `mingw_x64`. Минус: нет `linux_arm64`.
- **clikt-multiplatform 5.x** (ajalt) — имеет linuxArm64, но
ломается на native linker: `duplicate symbol selfAndAncestors`
между `clikt` и `clikt-mordant` commonMain (issue
[ajalt/clikt#598](https://github.com/ajalt/clikt/issues/598)).
Workaround `kotlin.native.cacheKind.linuxX64=none` замедляет
сборку на порядки и не решает проблему до конца. Поэтому clikt
отвергнут.
## Платформенные детали
- **entryPoint на K/N** — это FQN функции **без** суффикса `Kt`
(т.е. `pw.binom.agentik.cli.main`, а не `MainKt.main`). JVM
convention `MainKt.main` тут не работает — K/N линкер ищет
функцию по `package.main`.
- **`platformEnv(key)`** для чтения env-переменных:
- JVM: `System.getenv(key)` через `jvmMain` actual.
- Native: `getenv(key)` из `platform.posix` через
`kotlinx.cinterop.toKString()` (`nativeMain` actual,
требует `@OptIn(ExperimentalForeignApi::class)`).
- **Stdout / exit code** — работают на K/N через корутины.
## Готчасы kotlinx.cli
- **Вложенные subcommands + parent.execute().** В kotlinx.cli 0.3.6
`parent.execute()` вызывается ПОСЛЕ `leaf.execute()` всегда,
когда leaf был достигнут через parent. Поэтому `ConvCommand.execute()`
сделан no-op (`override fun execute() = Unit`), иначе вывод
дочерней команды дублируется выводом родителя. Дочерние команды
смотрятся через `agentik-cli conv --help`.
- **strictSubcommandOptionsOrder.** Без этого флага `conv new --server ...`
парсится как `conv [--server ...]` + позиционный аргумент `new`
на уровне родителя — и дочерняя команда не запускается.
В `ArgParser` сразу включается `strictSubcommandOptionsOrder = true`.
## Тесты
Тесты для подкоманд пока не написаны (TODO). Базовый smoke
покрывается руками против живого сервера.
```bash
./gradlew :agentik-cli:jvmTest # 0/0 — пока пусто
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agentik-cli`.
+94
View File
@@ -0,0 +1,94 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.shadow)
}
kotlin {
jvmToolchain(21)
// Native-таргеты, которые покрывает kotlinx.cli 0.3.6 (см. его .module
// в Maven Central): linux_x64, macos_x64, macos_arm64, mingw_x64.
// linuxArm64 не входит — kotlinx.cli 0.3.6 для него не публикуется
// (последний релиз 2023-09, KMP-targets зафиксированы). clikt-multiplatform
// 5.x имеет linuxArm64, но ломается на duplicate symbol `selfAndAncestors`
// между clikt и clikt-mordant при линковке native (issue ajalt/clikt#598),
// поэтому clikt отвергнут.
//
// iOS не входит: :agentik-cli бессмыслен на iOS, а :client (единственный
// его потребитель) тоже без iOS.
jvm()
listOf(
linuxX64(),
macosX64(),
macosArm64(),
mingwX64(),
)
sourceSets {
commonMain.dependencies {
implementation(project(":proto"))
implementation(project(":client"))
// kotlinx.cli 0.3.6 — KMP subcommand-парсер от JetBrains.
// clikt 5.x имеет upstream-баг: `duplicate symbol selfAndAncestors`
// между `clikt` и `clikt-mordant` при линковке native. kotlinx.cli
// таких проблем нет.
implementation(libs.kotlinx.cli)
implementation(libs.kotlinx.coroutines.core)
}
// :agentik-cli — commonMain-only (нет jvmMain/nativeMain разделения):
// весь код, включая platformEnv, лежит в commonMain.
}
@OptIn(ExperimentalKotlinGradlePluginApi::class)
jvm {
binaries {
executable {
mainClass.set("pw.binom.agentik.cli.AgentikCliKt")
}
}
}
// entryPoint на K/N — это FQN функции БЕЗ суффикса `Kt`
// (Java/Kotlin convention `MainKt.main` тут не работает, линкер K/N ищет
// функцию как `package.main`). На JVM суффикс `Kt` сохраняется через
// mainClass.set(...) выше.
listOf(
linuxX64(),
macosX64(),
macosArm64(),
mingwX64(),
).forEach {
it.binaries.executable {
entryPoint = "pw.binom.agentik.cli.main"
}
}
}
// Fatjar — аналог :standalone.
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
archiveBaseName.set("agentik-cli")
archiveClassifier.set("all")
description = "Self-contained fatjar with all runtime dependencies bundled."
group = "build"
from(tasks.named("jvmJar"))
from(project.configurations.getByName("jvmRuntimeClasspath"))
mergeServiceFiles()
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes["Main-Class"] = "pw.binom.agentik.cli.AgentikCliKt"
attributes["Implementation-Title"] = "agentik-cli"
attributes["Implementation-Version"] = project.version.toString()
}
includeEmptyDirs = false
}
@@ -0,0 +1,87 @@
package pw.binom.agentik.cli
import kotlinx.cli.ArgParser
import kotlinx.cli.ArgType
import kotlinx.cli.ExperimentalCli
import kotlinx.cli.Subcommand
import kotlinx.cli.default
import pw.binom.agentik.cli.commands.ConvCommand
import pw.binom.agentik.cli.commands.InfoSubcommand
import pw.binom.agentik.cli.commands.InterruptSubcommand
import pw.binom.agentik.cli.commands.MsgsSubcommand
import pw.binom.agentik.cli.commands.SendSubcommand
/**
* Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`.
* Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`.
*
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
* `--server`/`--id` (иначе subcommand не видит значения родителя).
*/
internal fun defaultServerUrl(): String = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
internal fun defaultAgentId(): String = platformEnv("AGENTIK_AGENT_ID") ?: "cli"
/**
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
*
* ```
* agentik-cli <command> [args...]
*
* Commands:
* conv ls|new|show|delete|rename операции над диалогами
* msgs <id> [--limit N] показать сообщения
* send <id> <text...> отправить ход, стримит response-события в stdout
* interrupt <id> прервать текущий ход
* info показать конфиг
*
* `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е.
* `agentik-cli conv ls --server http://...`), не до — kotlinx.cli не
* шарит опции родителя в subcommand.
*
* Вложенные subcommands (`conv ls`, `conv new`, ...) реализованы
* через [Subcommand.subcommands]: `conv` сам — subcommand, и его
* дочерние команды (`ls`, `new`, `show`, `delete`, `rename`)
* регистрируются у него.
*/
@OptIn(ExperimentalCli::class)
fun runAgentikCli(args: Array<String>) {
val parser = ArgParser(
programName = "agentik-cli",
// Все аргументы после имени subcommand должны передаваться
// В subcommand-парсер, а не парситься на уровне родителя.
// Без этого `conv new --server ...` парсится как `conv [--server ...]`
// + аргумент "new" → execute родителя, без вложенной команды.
strictSubcommandOptionsOrder = true,
)
val conv = ConvCommand()
parser.subcommands(
conv,
MsgsSubcommand(),
SendSubcommand(),
InterruptSubcommand(),
InfoSubcommand(),
)
parser.parse(args)
}
/**
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
* чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
* свойства родителя в subcommand).
*/
abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
val serverUrl: String by option(
ArgType.String, fullName = "server", shortName = "s",
description = "Base URL агента (env AGENTIK_SERVER)",
).default(defaultServerUrl())
val agentId: String by option(
ArgType.String, fullName = "id", shortName = "i",
description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
).default(defaultAgentId())
}
fun main(args: Array<String>) {
runAgentikCli(args)
}
@@ -0,0 +1,3 @@
package pw.binom.agentik.cli
internal expect fun platformEnv(key: String): String?
@@ -0,0 +1,32 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ExperimentalCli
import kotlinx.cli.Subcommand
import pw.binom.agentik.cli.AgentikSubcommand
/**
* Родительская группа `conv`: операции над диалогами.
*
* Сама команда `agentik-cli conv` (без подкоманды) — no-op:
* в kotlinx.cli parent.execute() вызывается ПОСЛЕ leaf.execute(),
* поэтому любая работа в execute() дублирует вывод дочерней команды.
* Для просмотра дочерних команд есть `agentik-cli conv --help`.
*
* Дочерние команды регистрируются через [subcommands] в конструкторе.
*/
@OptIn(ExperimentalCli::class)
class ConvCommand : Subcommand("conv", "Операции над диалогами") {
init {
subcommands(
ConvLsSubcommand(),
ConvNewSubcommand(),
ConvShowSubcommand(),
ConvDeleteSubcommand(),
ConvRenameSubcommand(),
)
}
override fun execute() = Unit
}
abstract class ConvSubcommand(name: String, description: String) : AgentikSubcommand(name, description)
@@ -0,0 +1,15 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvDeleteSubcommand : ConvSubcommand("delete", "Удалить диалог") {
val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val ok = agent.deleteConversation(id)
if (ok) println("deleted: $id") else println("conversation not found: $id")
}
}
@@ -0,0 +1,32 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent
class ConvLsSubcommand : ConvSubcommand("ls", "Список диалогов агента") {
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
if (convs.isEmpty()) {
println("(no conversations)")
return@runBlocking
}
println("ID UPDATED-AT TITLE FLAGS")
convs.forEach { c ->
val flags = buildString {
if (c.isTemporal) append('T')
if (c.isSupportImageInput) append('I')
if (c.isSupportImageOutput) append('O')
if (isEmpty()) append('-')
}
val title = c.title ?: "(untitled)"
println("${c.id.padEnd(38)} ${c.updatedAt.toString().padEnd(22)} ${title.take(30).padEnd(31)} $flags")
}
println("--- ${convs.size} conversation(s)")
}
}
@@ -0,0 +1,16 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvNewSubcommand : ConvSubcommand("new", "Создать диалог; печатает id") {
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.createConversation(temp = temp)
println(conv.id)
}
}
@@ -0,0 +1,24 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvRenameSubcommand : ConvSubcommand("rename", "Переименовать диалог") {
val id by argument(ArgType.String, description = "ID диалога")
val title by argument(ArgType.String, description = "Новое название")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
conv.rename(title)
} finally {
conv.close()
}
println("renamed: $id -> $title")
}
}
@@ -0,0 +1,27 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvShowSubcommand : ConvSubcommand("show", "Метаданные диалога") {
val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
println("id: ${conv.id}")
println("title: ${conv.title ?: "(untitled)"}")
println("updatedAt: ${conv.updatedAt}")
println("isTemporal: ${conv.isTemporal}")
println("isSupportImageInput: ${conv.isSupportImageInput}")
println("isSupportImageOutput: ${conv.isSupportImageOutput}")
} finally {
conv.close()
}
}
}
@@ -0,0 +1,10 @@
package pw.binom.agentik.cli.commands
import pw.binom.agentik.cli.AgentikSubcommand
class InfoSubcommand : AgentikSubcommand("info", "Показать server URL и agent id") {
override fun execute() {
println("server: $serverUrl")
println("id: $agentId")
}
}
@@ -0,0 +1,23 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
conv.interrupt()
println("interrupted: $id")
} finally {
conv.close()
}
}
}
@@ -0,0 +1,54 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Message
import kotlin.time.Instant
class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообщения диалога") {
val id by argument(ArgType.String, description = "ID диалога")
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
val msgs = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = limit)
.sortedBy { it.date }
msgs.forEach { m -> println(formatMessage(m)) }
println("--- ${msgs.size} message(s)")
} finally {
conv.close()
}
}
private fun formatMessage(m: Message): String =
"[${m.date}] ${m.role().padEnd(11)} ${m.bodyOneLine()}"
private fun Message.role(): String = when (this) {
is Message.UserMessage -> "[user]"
is Message.AssistantMessage -> "[assistant]"
is Message.ToolCall -> "[tool_call]"
is Message.ToolResult -> "[tool_result]"
is Message.Error -> "[error]"
}
private fun Message.bodyOneLine(): String = when (this) {
is Message.UserMessage -> content.joinToString(" ") { c -> c.toOneLine() }
is Message.AssistantMessage -> content.joinToString(" ") { c -> c.toOneLine() }
is Message.ToolCall -> "tool=$toolName args=$toolArgs"
is Message.ToolResult -> "id=$id result=${result ?: "<null>"}"
is Message.Error -> "code=${code ?: "?"} message=$message"
}
private fun Content.toOneLine(): String = when (this) {
is Content.Text -> body.replace('\n', ' ').take(200)
is Content.Image -> "<image ${data.size}B $mime>"
}
}
@@ -0,0 +1,63 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.vararg
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
val id by argument(ArgType.String, description = "ID диалога")
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
// Подписываемся на поток событий ДО send: события, отправленные
// до подписки, не реплеятся (shared-flow без replay).
val eventsJob = launch {
conv.events(Instant.DISTANT_PAST)
// onEach печатает и терминальный event, takeWhile лишь
// завершает сбор после него.
.onEach { ev -> emit(ev) }
.takeWhile { ev -> !isTerminal(ev) }
.collect { }
}
// Даём SSE-подписке установиться, затем шлём ход.
delay(200)
conv.send(listOf(Content.Text(text.joinToString(" "))))
eventsJob.join()
} finally {
conv.close()
}
}
private fun isTerminal(ev: Event): Boolean =
ev is Event.End || ev is Event.Interrupted || ev is Event.Error
private fun emit(ev: Event) {
when (ev) {
is Event.StartReasoning -> println("event StartReasoning")
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
is Event.End -> println("event End")
is Event.Interrupted -> println("event Interrupted")
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
}
}
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
}
@@ -0,0 +1,3 @@
package pw.binom.agentik.cli
internal actual fun platformEnv(key: String): String? = System.getenv(key)
@@ -0,0 +1,8 @@
package pw.binom.agentik.cli
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
import platform.posix.getenv
@OptIn(ExperimentalForeignApi::class)
internal actual fun platformEnv(key: String): String? = getenv(key)?.toKString()
+103
View File
@@ -0,0 +1,103 @@
# `:agentik-tui` — Compose-for-Mosaic TUI-клиент к `/agentik`
## Что это
Compose-style TUI-клиент в терминале на базе
[Mosaic](https://github.com/JakeWharton/mosaic) (Jetpack Compose
runtime, рендерится в ANSI-коды). Без `:`-команд (без vim-style
prompt): клавиатурная навигация Tab/Enter/Esc/Ctrl-D/F1/стрелки +
жирный focus indicator.
- **Layout**: header (id/conv/focus) + history + input + footer.
- **Focus**: Tab/Shift-Tab цикл по фокусам (input → history → sidebar).
- **Input**: стандартное текстовое поле с курсором `|` посередине.
- **Stream**: подписка на SSE в фон-корутинах, `StateFlow` + `collectAsState()`
для UI-реактивности (см. Snake sample).
Решает: полноценный TUI-клиент для тех, кто предпочитает мышкой
кликать в терминале больше, чем печатать. В отличие от `:agentik-cli`,
показывает историю диалога и текущий стрим в одном окне.
## Как запустить
### Требования
- JVM 21+.
- Запущенный `:standalone` (по умолчанию `http://localhost:8080/agentik`).
- Реальный TTY (через `ssh -tt`, `tmux`, либо нативный terminal).
### Запуск из готового fatjar
```bash
java --enable-native-access=ALL-UNNAMED \
-jar agentik-tui-0.1.0-all.jar \
--server http://192.168.76.166:8080/agentik
```
`--enable-native-access=ALL-UNNAMED` обязателен — Mosaic использует
native syscalls для терминала.
### Запуск через Gradle (dev)
```bash
./gradlew :agentik-tui:run --args="--server http://localhost:8080/agentik"
```
## Параметры CLI
| Флаг | ENV | Что делает |
|---|---|---|
| `--server URL` | `AGENTIK_SERVER` | URL `/agentik` (default `http://localhost:8080/agentik`) |
| `--id ID` | `USER`/`USERNAME` | Имя агента (default — текущий пользователь) |
| `--no-history` | — | Не восстанавливать последнюю диалог после запуска |
| `--help` | — | Показывает help и выходит |
## Keybindings
| Клавиша | Когда | Что делает |
|---|---|---|
| `Tab` / `Shift-Tab` | глобально | Цикл фокусов: input → history → sidebar → ... |
| `F1` | глобально | Toggle help overlay |
| `Esc` | в input | Очистить input |
| `Enter` | в input | Submit message |
| `Backspace` / `Del` | в input | Удалить символ |
| `←` `→` `Home` `End` | в input | Курсор |
| `↑` `↓` | в history | Scrollback |
| `Ctrl-D` / `Ctrl-C` | — | Exit (TODO — пока работает только вне стрима) |
## Переменные среды (сервера)
См. [`../standalone/README.md`](../standalone/README.md). TUI
получает URL сервера через `--server`, остальное настройка
агента, а не клиента.
## Известное ограничение
1. **SSE в не-TTY ssh закрывается на default Ktor timeout** — то
же, что для `:agentik-cli`.
2. **Mouse events не подключены** в v2 (Mosaic 0.18 не имеет
built-in mouse-runtime). Планируется в v3 через termios
SGR-mouse.
3. **Нативные target'ы (macOS / Linux x64+ARM64 / Windows x64)**
собраны, но без `:client` (он JVM-only). Для нативной работы
нужен альтернативный HTTP-клиент.
## Тесты
```
./gradlew :agentik-tui:jvmTest
```
Тесты composable'ов и event-рендеринга. Включает smoke-test для
key-event → AppState mutation → ре-рендер.
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-agentik-tui`.
## Архитектурная заметка
UI-стейт держится в `StateFlow`, а **не** в Compose `mutableStateOf`.
Причина: Mosaic 0.18 не триггерит recomposition от `mutableStateOf`
-writes внутри `onPreviewKeyEvent`-handler'ов (см. Snake sample в
репо Mosaic — они тоже используют `StateFlow` + `collectAsState()`).
+110
View File
@@ -0,0 +1,110 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.shadow)
}
kotlin {
jvmToolchain(21)
// Suppress Beta-предупреждения от expect/actual объектов.
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
// Mosaic 0.18 поддерживает JVM + desktop-native (macosX64/macosArm64/linuxX64/linuxArm64/mingwX64).
// iOS пропускаем — на iOS не бывает TUI-сессий.
jvm()
macosX64()
macosArm64()
linuxX64()
linuxArm64()
mingwX64()
// Native executables. По умолчанию Kotlin/Native для каждого target'а
// собирает только .klib (библиотеку) — для запускаемого .kexe надо
// явно попросить binaries.executable(). entryPoint нужно задать явно:
// KMP-линкер ищет функцию по FQN (без `Kt`-суффикса), а Kotlin/Native
// добавляет суффикс только для файлов с именем `Main.kt`, поэтому
// указываем точку входа как `pw.binom.agentik.tui.main` (без суффикса).
//
// Применяем к каждому из linuxX64/macosX64/macosArm64/linuxArm64/mingwX64
// явно (а не через targets.withType), потому что targets DSL в KMP не
// поддерживает реифицированный withType<KotlinNativeTarget>().
@OptIn(ExperimentalKotlinGradlePluginApi::class)
listOf(linuxX64(), linuxArm64(), macosX64(), macosArm64(), mingwX64()).forEach {
it.binaries.executable {
entryPoint = "pw.binom.agentik.tui.main"
}
}
sourceSets {
commonMain.dependencies {
implementation(project(":proto"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// JetBrains Compose runtime — тащит Mosaic как обёртку.
implementation(libs.mosaic.runtime)
implementation(libs.mosaic.tty.terminal)
// Health-check в Main.kt: Ktor CIO на JVM, на native не собирается —
// там работает stub actual через expect/actual.
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
}
jvmMain.dependencies {
implementation(project(":client"))
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.test)
}
}
@OptIn(ExperimentalKotlinGradlePluginApi::class)
jvm {
binaries {
executable {
mainClass.set("pw.binom.agentik.tui.MainKt")
}
}
}
}
// --- Fatjar (uberjar) ---
//
// Аналогично `:agentik-cli`: shadowJar склеивает `jvmJar` + `jvmRuntimeClasspath` в self-contained
// `*-all.jar`. Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — регистрируем явно.
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
archiveBaseName.set("agentik-tui")
archiveClassifier.set("all")
description = "Self-contained fatjar with all runtime dependencies bundled (incl. Compose-runtime + Mosaic)."
group = "build"
from(tasks.named("jvmJar"))
val cc = try {
@Suppress("UNCHECKED_CAST")
configurations as org.gradle.api.artifacts.ConfigurationContainer
} catch (_: ClassCastException) {
@Suppress("UNCHECKED_CAST")
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
}
from(cc.getByName("jvmRuntimeClasspath"))
mergeServiceFiles()
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes["Main-Class"] = "pw.binom.agentik.tui.MainKt"
attributes["Implementation-Title"] = "agentik-tui"
attributes["Implementation-Version"] = project.version.toString()
}
includeEmptyDirs = false
}
@@ -0,0 +1,61 @@
package pw.binom.agentik.tui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Row
import pw.binom.agentik.tui.ui.Footer
import pw.binom.agentik.tui.ui.Header
import pw.binom.agentik.tui.ui.HelpOverlay
import pw.binom.agentik.tui.ui.HistoryPanel
import pw.binom.agentik.tui.ui.InputLine
/**
* Корневая Compose-композиция TUI. Содержит только каркас + глобальный key-handler;
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
*
* Layout (минимальный):
* ```
* ┌─────────────────────────────────────────────────────────┐
* │ HEADER: agentik · id · conv-id · focus=… │
* ├─────────────────────────────────────────────────────────┤
* │ HISTORY (весь актуальный диалог) │
* ├─────────────────────────────────────────────────────────┤
* │ INPUT LINE: > text| │
* ├─────────────────────────────────────────────────────────┤
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
* └─────────────────────────────────────────────────────────┘
* ```
*
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
*/
@Composable
internal fun App(state: AppState) {
val focusIndex by state.focusIndex.collectAsState()
val showHelp by state.showHelp.collectAsState()
Row(modifier = Modifier.onPreviewKeyEvent { ev ->
when (ev.key) {
"Tab" -> { state.cycleFocus(direction = if (ev.shift) -1 else +1); true }
"F1" -> { state.toggleHelp(); true }
"Escape", "Esc" -> {
if (showHelp) state.setShowHelp(false)
else if (focusIndex == 0) state.inputClear()
true
}
else -> false
}
}) {
Column(modifier = Modifier.weight(1f)) {
Header(state, focusIndex)
HistoryPanel(state)
InputLine(state)
Footer(showHelp)
}
}
if (showHelp) HelpOverlay()
}
@@ -0,0 +1,183 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.time.Instant
/**
* Состояние TUI. По дизайну — singleton, переживает все экраны.
*
* Используем [StateFlow] вместо Compose [androidx.compose.runtime.mutableStateOf]
* потому что в Mosaic 0.18 recomposition от `mutableStateOf`-writes из key-event
* handlers работает нестабильно (требует ручного [androidx.compose.runtime.Snapshot]
* apply). `StateFlow` + `collectAsState()` — работает out-of-the-box
* (см. samples/snake в репо Mosaic).
*/
internal class AppState(val config: TuiConfig) {
/** Бэкенд, прикреплённый из TuiApp — маршрутизирует submitInput → send. */
private var backend: TuiBackend? = null
fun attachBackend(b: TuiBackend) { backend = b }
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
private val _focusIndex = MutableStateFlow(0)
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
/** Видимость help-оверлея. */
private val _showHelp = MutableStateFlow(false)
val showHelp: StateFlow<Boolean> = _showHelp.asStateFlow()
/** Сообщения диалога. */
private val _messages = MutableStateFlow<List<TuiMessage>>(emptyList())
val messages: StateFlow<List<TuiMessage>> = _messages.asStateFlow()
/** Заголовок текущего диалога. */
private val _currentTitle = MutableStateFlow<String?>(null)
val currentTitle: StateFlow<String?> = _currentTitle.asStateFlow()
/** ID текущего диалога. */
private val _currentConversationId = MutableStateFlow<String?>(null)
val currentConversationId: StateFlow<String?> = _currentConversationId.asStateFlow()
/** Список диалогов (sidebar). */
private val _conversations = MutableStateFlow<List<ConvSummary>>(emptyList())
val conversations: StateFlow<List<ConvSummary>> = _conversations.asStateFlow()
/** Курсор в списке диалогов. */
private val _conversationsCursor = MutableStateFlow(0)
val conversationsCursor: StateFlow<Int> = _conversationsCursor.asStateFlow()
/** Поле ввода. */
private val _input = MutableStateFlow("")
val input: StateFlow<String> = _input.asStateFlow()
/** Курсор в input (offset в chars). */
private val _cursor = MutableStateFlow(0)
val cursor: StateFlow<Int> = _cursor.asStateFlow()
/** Идёт ли стрим. */
private val _streaming = MutableStateFlow(false)
val streaming: StateFlow<Boolean> = _streaming.asStateFlow()
/** Scrollback index: 0 = прижат к низу. */
private val _historyScroll = MutableStateFlow(0)
val historyScroll: StateFlow<Int> = _historyScroll.asStateFlow()
// ---------- мутации ----------
fun cycleFocus(direction: Int = +1) {
_focusIndex.value = (_focusIndex.value + direction).mod(3)
}
fun toggleHelp() { _showHelp.value = !_showHelp.value }
fun setShowHelp(v: Boolean) { _showHelp.value = v }
fun inputInsert(s: String) {
val pos = _cursor.value.coerceIn(0, _input.value.length)
_input.value = _input.value.substring(0, pos) + s + _input.value.substring(pos)
_cursor.value = pos + s.length
}
fun inputBackspace() {
val pos = _cursor.value
if (pos <= 0) return
_input.value = _input.value.substring(0, pos - 1) + _input.value.substring(pos)
_cursor.value = pos - 1
}
fun inputDelete() {
val pos = _cursor.value
if (pos >= _input.value.length) return
_input.value = _input.value.substring(0, pos) + _input.value.substring(pos + 1)
}
fun inputClear() { _input.value = ""; _cursor.value = 0 }
fun inputMoveCursor(delta: Int) {
_cursor.value = (_cursor.value + delta).coerceIn(0, _input.value.length)
}
fun inputCursorHome() { _cursor.value = 0 }
fun inputCursorEnd() { _cursor.value = _input.value.length }
fun submitInput(): String? {
val text = _input.value.trim()
if (text.isEmpty()) return null
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
inputClear()
_streaming.value = true
backend?.onUserMessage(text)
return text
}
fun setStreaming(v: Boolean) { _streaming.value = v }
fun setConversation(id: String, title: String?) {
_currentConversationId.value = id
_currentTitle.value = title
_messages.value = emptyList()
_historyScroll.value = 0
_streaming.value = false
}
fun postToolCall(toolName: String, title: String?, args: String) {
_messages.value = _messages.value + TuiMessage.ToolCall(toolName = toolName, title = title, args = args, ts = nowInstant())
}
fun postToolResult(toolName: String, result: String) {
_messages.value = _messages.value + TuiMessage.ToolResult(toolName = toolName, result = result, ts = nowInstant())
}
fun appendAssistant(chunk: String) {
val list = _messages.value.toMutableList()
val last = list.lastOrNull()
if (last is TuiMessage.AssistantStreaming) {
list[list.lastIndex] = last.copy(text = last.text + chunk)
} else {
list.add(TuiMessage.AssistantStreaming(text = chunk, ts = nowInstant()))
}
_messages.value = list
}
fun finishAssistant() {
val list = _messages.value.toMutableList()
val last = list.lastOrNull() ?: return
if (last is TuiMessage.AssistantStreaming) {
list[list.lastIndex] = TuiMessage.Assistant(text = last.text, ts = last.ts)
_messages.value = list
}
_streaming.value = false
}
fun newConversation(id: String, title: String?) {
_currentConversationId.value = id
_currentTitle.value = title
_messages.value = emptyList()
_historyScroll.value = 0
_streaming.value = false
}
fun postSystem(text: String) {
_messages.value = _messages.value + TuiMessage.System(text = text, ts = nowInstant())
}
}
/** Снимок диалога для sidebar. */
internal data class ConvSummary(
val id: String,
val title: String?,
val updatedAt: Instant,
)
/** Рендер-единица. */
internal sealed interface TuiMessage {
val ts: Instant
data class System(val text: String, override val ts: Instant) : TuiMessage
data class User(val text: String, override val ts: Instant) : TuiMessage
data class AssistantStreaming(val text: String, override val ts: Instant) : TuiMessage
data class Assistant(val text: String, override val ts: Instant) : TuiMessage
data class ToolCall(val toolName: String, val title: String?, val args: String, override val ts: Instant) : TuiMessage
data class ToolResult(val toolName: String, val result: String, override val ts: Instant) : TuiMessage
}
internal fun nowInstant(): Instant = kotlin.time.Clock.System.now()
@@ -0,0 +1,161 @@
package pw.binom.agentik.tui
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.Agent
/**
* Точка входа TUI-клиента agentik.
*
* ```
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
* ```
*
* Перед запуском UI — обязательный health-check: `GET {server}/health`.
* Если сервер недоступен — печатаем понятную ошибку и выходим с кодом 1.
* Если OK — создаём [Agent] через платформенную actual и запускаем
* [TuiApp].
*/
fun main(args: Array<String>) = runBlocking {
val cfg = parseCliArgs(args) ?: run {
printUsage()
return@runBlocking
}
checkServer(cfg.server)
val agent = platformCreateAgent(cfg.server, cfg.id)
TuiApp(cfg, agent).run()
}
/**
* Делает синхронный GET `{baseUrl}/health`. Внутри [route(path)] на сервере
* `/health` зарегистрирован под тем же path-prefix'ом, что и сам API
* (например, baseUrl = `http://localhost:8080/agentik` → health = …/agentik/health).
*
* При любой ошибке (connect refused, timeout, не-200 ответ, не `"ok"`) —
* бросает [IllegalStateException] с понятным сообщением. [runBlocking]-обёртка
* в [main] разворачивает её в stack-trace и `exit 1`.
*/
private suspend fun checkServer(baseUrl: String) {
val healthUrl = "${baseUrl.trimEnd('/')}/health"
val client = HttpClient(CIO) {
install(HttpTimeout) {
requestTimeoutMillis = 5_000
connectTimeoutMillis = 3_000
}
expectSuccess = false
}
try {
val response = client.get(healthUrl)
if (response.status.value !in 200..299) {
throw IllegalStateException("сервер ответил HTTP ${response.status.value} на GET $healthUrl")
}
val body = response.bodyAsText().trim()
if (body != "ok") {
throw IllegalStateException("сервер ответил неожиданным телом на GET $healthUrl: '$body'")
}
} catch (e: IllegalStateException) {
throw e
} catch (e: Exception) {
// На JVM сюда упадут java.net.ConnectException, UnknownHostException,
// io.ktor.client.network.sockets.ConnectTimeoutException и т.п.
// На нативе native stub падает раньше в platformCreateAgent, так что
// сюда мы попадём только под JVM-actual.
throw IllegalStateException(
"ошибка health-check $healthUrl: ${e::class.simpleName} — ${e.message ?: "(нет сообщения)"}",
e,
)
} finally {
client.close()
}
}
/**
* Конфигурация TUI, вычисленная из аргументов + переменных среды.
* Доступна из других файлов commonMain как `internal`.
*/
internal data class TuiConfig(
val server: String,
val id: String,
val historyEnabled: Boolean,
)
private fun parseCliArgs(args: Array<String>): TuiConfig? {
var server: String? = null
var id: String? = null
var historyEnabled = true
var i = 0
while (i < args.size) {
when (val a = args[i]) {
"--help", "-h", "help" -> return null
"--server", "-s" -> {
require(i + 1 < args.size) { "$a требует URL" }
server = args[i + 1]; i += 2
}
"--id" -> {
require(i + 1 < args.size) { "$a требует значение" }
id = args[i + 1]; i += 2
}
"--no-history" -> { historyEnabled = false; i++ }
"--" -> i++
else -> error("неизвестный аргумент: $a (введите --help)")
}
}
val envServer = platformEnv("AGENTIK_SERVER")
val envUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
val resolvedServer = server ?: envServer ?: "http://localhost:8080/agentik"
val resolvedId = id ?: "cli-tui:${envUser}"
return TuiConfig(
server = resolvedServer,
id = resolvedId,
historyEnabled = historyEnabled,
)
}
/**
* Читает переменную среды. JVM actual — `System.getenv`, native actual — `getenv()` через cinterop.
* Доступ к environment делается через expect/actual, чтобы commonMain не тащил JVM-пакеты.
*/
internal expect fun platformEnv(key: String): String?
/**
* Создаёт платформенную реализацию [Agent]. JVM actual подключает `:client`
* и ходит в HTTP-фасад; native actual пока возвращает stub (см. Platform.native.kt).
*/
internal expect fun platformCreateAgent(baseUrl: String, id: String): Agent
private fun printUsage() {
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
println("""
agentik-tui — Compose-Mosaic UI поверх протокола agentik
Использование:
agentik-tui [--server URL] [--id ID] [--no-history]
Аргументы:
--server, -s URL базовый URL (default: $defaultServer)
--id ID идентификатор клиента (default: cli-tui:${defaultUser})
--no-history не сохранять состояние
--help, -h эта справка
Переменные среды:
AGENTIK_SERVER базовый URL (эквивалент --server)
USER / USERNAME используется в id клиента по умолчанию
В UI:
Tab / Shift-Tab переключить фокус между историей и вводом
↑ / ↓ скроллить историю / двигать курсор в инпуте
← / → двинуть курсор в инпуте
Enter отправить сообщение (создаст новый диалог, если их нет)
Ctrl-C / Ctrl-D выйти
F1 показать подсказки по горячим клавишам
""".trimIndent())
}
@@ -0,0 +1,32 @@
package pw.binom.agentik.tui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import com.jakewharton.mosaic.runMosaicBlocking
import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent
/**
* Корневая точка запуска UI. Стартует Mosaic-рантайм, монтирует [TuiBackend] в
* его coroutine-scope и ждёт завершения приложения.
*
* Бэкенд — единый singleton на процесс: UI-композиция, сетевые подписки и
* coroutine job'ы делят scope [runMosaicBlocking] (через [LaunchedEffect]).
*/
internal class TuiApp(
private val config: TuiConfig,
private val agent: Agent,
) {
fun run() {
runMosaicBlocking {
val state = remember { AppState(config) }
val backend = remember { TuiBackend(state = state, agent = agent) }
LaunchedEffect(backend) {
backend.start(this)
}
state.attachBackend(backend)
App(state = state)
}
}
}
@@ -0,0 +1,138 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import kotlin.coroutines.CoroutineContext
import kotlin.time.Instant
/**
* Backend-логика TUI: мост между [Agent] и [AppState].
*
* Жизненный цикл:
* 1. На старте [start] — health-check сделан в [Main] ДО Mosaic; здесь только
* пост-сообщение "connected to …".
* 2. Подписка на [Agent.events] — обновление списка диалогов в sidebar.
* 3. При [onUserMessage] — если текущего диалога нет, создаём
* [createConversation] (temp=false, чтобы он персистился на сервере), затем
* [send]. Подписка на [Conversation.events] идёт сразу при создании/открытии.
*
* Дизайн: один backend-объект на процесс, живёт в [runMosaicBlocking]-scope.
*/
internal class TuiBackend(
private val state: AppState,
private val agent: Agent,
) {
/** Текущий открытый диалог, либо `null`, если ещё не выбран. */
private var current: Conversation? = null
/** Активная джоба подписки на [Conversation.events]. */
private var eventsJob: Job? = null
/** Последний виденный момент событий — для переподписки при reconnect. */
private var lastSeenAt: Instant = Instant.DISTANT_PAST
/**
* Запускает фоновые подписки в scope [scope] (передаётся из Mosaic
* LaunchedEffect'а — это scope recomposer'а, живёт до закрытия UI).
*/
fun start(scope: CoroutineScope) {
this.scope = scope
state.postSystem("подключено к ${state.config.server}")
scope.launch {
try {
agent.events(Instant.DISTANT_PAST).collect { /* sidebar refresh */ }
} catch (_: kotlinx.coroutines.CancellationException) {
// штатная отмена при закрытии UI
} catch (e: Exception) {
state.postSystem("ошибка live-events: ${e.message ?: e::class.simpleName}")
}
}
}
private lateinit var scope: CoroutineScope
/**
* Обработка пользовательского сообщения, отправленного из input.
*
* Если текущего диалога нет — создаём его; затем `send`. Подписка на
* события конкретного диалога стартует в [ensureConversation].
*/
fun onUserMessage(text: String) {
scope.launch {
try {
val conv = ensureConversation()
conv.send(listOf(Content.Text(text)))
} catch (e: Exception) {
state.postSystem("ошибка отправки: ${e.message ?: e::class.simpleName}")
state.setStreaming(false)
}
}
}
/**
* Создаёт [Conversation], если ещё не было; открывает подписку на её события.
*/
private suspend fun ensureConversation(): Conversation {
current?.let { return it }
val conv = agent.createConversation(temp = false)
state.setConversation(id = conv.id, title = conv.title)
subscribeEvents(conv, Instant.DISTANT_PAST)
current = conv
return conv
}
/**
* Подписывается на [Conversation.events] и перенаправляет их в [state].
*/
private fun subscribeEvents(conv: Conversation, from: Instant) {
eventsJob?.cancel()
eventsJob = scope.launch {
conv.events(from).collect { ev -> dispatch(ev) }
}
}
/**
* Маппинг [Event] → [AppState] (что показать в TUI).
*
* - AppendText → дописывает в последний ассистентский чанк
* - StartReasoning / StartResponse → новый streaming-чанк
* - End → закрывает streaming
* - Interrupted → закрывает streaming + системное сообщение
* - ToolCall / ToolResult → сообщения в историю
* - Error → системное сообщение
*/
private fun dispatch(ev: Event) {
lastSeenAt = ev.date
when (ev) {
is Event.AppendText -> state.appendAssistant(ev.body)
is Event.StartReasoning -> {
state.postSystem("… думаю")
}
is Event.StartResponse -> state.setStreaming(true)
is Event.End -> state.finishAssistant()
is Event.Interrupted -> {
state.finishAssistant()
state.postSystem("прервано")
}
is Event.AppendImage -> {
state.postSystem("[картинка: ${ev.mime}, ${ev.body.size} байт]")
}
is Event.ToolCall -> {
state.postToolCall(toolName = ev.toolName, title = null, args = ev.toolArgs)
}
is Event.ToolResult -> {
state.postToolResult(toolName = "", result = ev.result ?: "")
}
is Event.Error -> {
state.setStreaming(false)
state.postSystem("ошибка: ${ev.message}")
}
}
}
}
@@ -0,0 +1,17 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.TextStyle
/**
* Нижняя подсказка с текущим набором горячих клавиш.
*
* При открытом help-оверлее показывает заглушку с указателем «наверху».
*/
@Composable
internal fun Footer(showHelp: Boolean) {
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
Text(value = hint, textStyle = TextStyle.Italic)
}
@@ -0,0 +1,26 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.TextStyle
import pw.binom.agentik.tui.AppState
/**
* Верхняя инвертированная полоса с идентификатором и текущим фокусом.
*
* Пример: ` agentik · cli-tui:root · a1b2c3d4… · мой чат · focus=input `
*/
@Composable
internal fun Header(state: AppState, focusIndex: Int) {
val title by state.currentTitle.collectAsState()
val convId by state.currentConversationId.collectAsState()
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
val titleStr = title ?: "(нет диалога)"
Text(
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
textStyle = TextStyle.Bold + TextStyle.Invert,
)
}
@@ -0,0 +1,26 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.TextStyle
/**
* Полноэкранный оверлей со списком горячих клавиш.
* Включается/выключается по F1 (см. [pw.binom.agentik.tui.App]).
*/
@Composable
internal fun HelpOverlay() {
Column(modifier = Modifier) {
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
Text(value = " ↑ / ↓ скролл истории / курсор в input")
Text(value = " ← / → курсор в input")
Text(value = " Enter отправить сообщение")
Text(value = " Backspace / Del удалить символ")
Text(value = " Esc очистить input")
Text(value = " Ctrl-D / Ctrl-C выход")
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
}
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.ui.Text
import pw.binom.agentik.tui.AppState
import pw.binom.agentik.tui.TuiMessage
/**
* Прокручиваемый (через клавиатуру) лог диалога.
* Каждое сообщение рендерится отдельной строкой с префиксом (см. [renderMessage]).
* При пустом списке показывается подсказка.
*/
@Composable
internal fun HistoryPanel(state: AppState) {
val messages by state.messages.collectAsState()
val rendered = if (messages.isEmpty()) {
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
} else {
messages.joinToString("") { renderMessage(it) }
}
Text(value = rendered)
}
/** Превращает [TuiMessage] в одну строку с префиксом. Потоковые чанки получают курсор `▍`. */
internal fun renderMessage(m: TuiMessage): String = when (m) {
is TuiMessage.System -> " ── ${m.text}\n"
is TuiMessage.User -> " > ${m.text}\n"
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
}
@@ -0,0 +1,67 @@
package pw.binom.agentik.tui.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.jakewharton.mosaic.layout.KeyEvent
import com.jakewharton.mosaic.layout.drawBehind
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Text
import pw.binom.agentik.tui.AppState
/**
* Нижняя строка ввода с курсором.
* При активном стриме ассистента показывает ` ⋯`, иначе ` >`.
*
* Содержимое строки: `<prompt> <before>|<cursorChar>|<after>` —
* `cursorChar` — это символ, на котором стоит курсор (или пробел в конце).
*
* Клавиши обрабатываются через [handleInputKey] внутри `onPreviewKeyEvent`.
*/
@Composable
internal fun InputLine(state: AppState) {
val text by state.input.collectAsState()
val cursor by state.cursor.collectAsState()
val streaming by state.streaming.collectAsState()
val cursorPos = cursor.coerceIn(0, text.length)
val before = text.substring(0, cursorPos)
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
val after = text.substring(afterStart.coerceAtMost(text.length))
val prompt = if (streaming) " ⋯" else " >"
Text(
value = "$prompt $before|$cursorChar|${after}",
modifier = Modifier
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
.drawBehind {
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
state.input.let { /* touch */ }
},
)
}
/**
* Обработка клавиш в [InputLine]. `true` = событие поглощено.
*
* Не перехватывает клавиши с `alt`/`ctrl` — они идут дальше
* на корневой обработчик ([pw.binom.agentik.tui.App]).
*/
internal fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
if (ev.alt || ev.ctrl) return false
return when (ev.key) {
"Enter" -> state.submitInput() != null
"Backspace" -> { state.inputBackspace(); true }
"Delete" -> { state.inputDelete(); true }
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
"Home" -> { state.inputCursorHome(); true }
"End" -> { state.inputCursorEnd(); true }
else -> {
val s = ev.key
if (s.length == 1) { state.inputInsert(s); true }
else false
}
}
}
@@ -0,0 +1,85 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import pw.binom.agentik.proto.MessageContext
import kotlin.time.Instant
/**
* Минимальный fake [Agent] для тестов [TuiBackend]: считает, сколько раз
* вызвали [createConversation], и отдаёт заранее сконструированные
* [FakeConversation].
*/
internal class FakeAgent(
private val conversationFactory: () -> FakeConversation = { FakeConversation() },
) : Agent {
override val id: String = "fake"
var createCount: Int = 0
private set
val conversations = mutableListOf<FakeConversation>()
override fun createConversation(temp: Boolean): Conversation {
createCount++
val c = conversationFactory()
conversations += c
return c
}
override suspend fun getConversation(id: String): Conversation? =
conversations.firstOrNull { it.id == id }
override suspend fun deleteConversation(id: String): Boolean =
conversations.removeAll { it.id == id }
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> =
conversations.toList()
override fun events(after: Instant): Flow<AgentEvent> = emptyFlow()
}
/**
* [Conversation], запоминающий все вызовы [send] и эмитящий управляемые
* [Event] через общий [MutableSharedFlow]. Используется в тестах
* [TuiBackend] для проверки маршрутизации событий в UI.
*/
internal class FakeConversation(
override val id: String = "fake-conv",
override val title: String? = null,
) : Conversation {
override val isSupportImageInput: Boolean = false
override val isSupportImageOutput: Boolean = false
override val isTemporal: Boolean = false
override val updatedAt: Instant = Instant.DISTANT_PAST
val sent = mutableListOf<List<Content>>()
val sentContexts = mutableListOf<MessageContext?>()
var closed: Boolean = false
private set
var interrupted: Boolean = false
private set
private val eventsFlow = MutableSharedFlow<Event>(extraBufferCapacity = 64)
fun emit(e: Event) { eventsFlow.tryEmit(e) }
override suspend fun rename(title: String) = Unit
override suspend fun send(content: List<Content>, context: MessageContext?) {
sent += content
sentContexts += context
}
override suspend fun interrupt() { interrupted = true }
override fun events(after: Instant): Flow<Event> = eventsFlow
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> = emptyList()
override fun close() { closed = true }
}
@@ -0,0 +1,249 @@
package pw.binom.agentik.tui
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Тесты [TuiBackend]. Используем [runTest.backgroundScope] (а не TestScope)
* для передачи в `start` — фоновые подписки должны жить параллельно с
* телом теста и автоматически отменяться по его завершении. Иначе
* бесконечный collect на `agent.events()` завешивает runTest на 60s
* `UncompletedCoroutinesError`.
*
* [runCurrent] нужен после каждого `onUserMessage` и каждого `emit`,
* потому что `backgroundScope` использует свой диспетчер, который не
* продвигается через `advanceUntilIdle` — `runCurrent` прогоняет ровно
* те задачи, что готовы к запуску сейчас.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TuiBackendTest {
private fun fixtureConfig(server: String = "http://localhost:8080/agentik") =
TuiConfig(server = server, id = "cli-tui:tester", historyEnabled = true)
@Test
fun `start posts connected system message`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent()
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(
sysMsgs.any { it.text.contains(cfg.server) },
"ожидалось системное 'подключено к ${cfg.server}', было: ${sysMsgs.map { it.text }}",
)
}
@Test
fun `first onUserMessage auto-creates conversation with temp=false`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent()
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("привет")
runCurrent()
assertEquals(1, agent.createCount, "должен быть один createConversation")
assertEquals(listOf("привет"), agent.conversations.first().sent.flattenText())
// temp=false — обычный (не временный) диалог: персистится на сервере
assertFalse(agent.conversations.first().isTemporal, "диалог не должен быть временным")
// state знает id и title нового диалога
assertEquals("fake-conv", state.currentConversationId.value)
}
@Test
fun `second onUserMessage reuses same conversation`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent()
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("раз")
runCurrent()
backend.onUserMessage("два")
runCurrent()
assertEquals(1, agent.createCount, "новый диалог создавать не должны — переиспользуем старый")
assertEquals(2, agent.conversations.first().sent.size)
}
@Test
fun `AppendText appends to current assistant streaming chunk`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.AppendText(now, "Привет"))
conv.emit(Event.AppendText(now, ", мир"))
runCurrent()
val assistantMsgs = state.messages.value.filterIsInstance<TuiMessage.AssistantStreaming>()
assertEquals(1, assistantMsgs.size, "должен быть один streaming-чанк, не два")
assertEquals("Привет, мир", assistantMsgs.single().text)
assertTrue(state.streaming.value)
}
@Test
fun `End event finalizes assistant and stops streaming`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.AppendText(now, "ответ"))
conv.emit(Event.End(now))
runCurrent()
val last = state.messages.value.last()
assertTrue(last is TuiMessage.Assistant, "после End последнее сообщение должно стать финальным Assistant, было: ${last::class.simpleName}")
assertEquals("ответ", (last as TuiMessage.Assistant).text)
assertFalse(state.streaming.value)
}
@Test
fun `Interrupted event clears streaming and posts system message`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.AppendText(now, "часть ответа"))
conv.emit(Event.Interrupted(now))
runCurrent()
assertFalse(state.streaming.value)
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(
sysMsgs.any { it.text.contains("прервано") },
"ожидалось 'прервано' в системных сообщениях, было: ${sysMsgs.map { it.text }}",
)
}
@Test
fun `ToolCall and ToolResult events become visible tool messages`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.ToolCall(date = now, id = "1", title = null, toolName = "echo", toolArgs = """{"x":1}"""))
conv.emit(Event.ToolResult(date = now, id = "1", result = "ok"))
runCurrent()
val toolMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolCall>()
val resultMsgs = state.messages.value.filterIsInstance<TuiMessage.ToolResult>()
assertEquals(1, toolMsgs.size)
assertEquals("echo", toolMsgs.single().toolName)
assertEquals("""{"x":1}""", toolMsgs.single().args)
assertEquals(1, resultMsgs.size)
assertEquals("ok", resultMsgs.single().result)
}
@Test
fun `Error event posts system message and clears streaming`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartResponse(now, Event.ResponseType.TEXT))
conv.emit(Event.Error(date = now, message = "boom"))
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(sysMsgs.any { it.text.contains("boom") }, "должно быть 'ошибка: boom'")
assertFalse(state.streaming.value)
}
@Test
fun `onUserMessage does not swallow exceptions - state stays consistent`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val agent = FakeAgent(conversationFactory = { error("server kaboom") })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(
sysMsgs.any { it.text.contains("ошибка отправки") || it.text.contains("server kaboom") },
"должна быть системная ошибка, было: ${sysMsgs.map { it.text }}",
)
assertFalse(state.streaming.value, "стриминг должен быть выключен в catch-ветке")
}
@Test
fun `StartReasoning posts thinking system message`() = runTest {
val cfg = fixtureConfig()
val state = AppState(cfg)
val conv = FakeConversation()
val agent = FakeAgent(conversationFactory = { conv })
val backend = TuiBackend(state = state, agent = agent)
backend.start(backgroundScope)
runCurrent()
backend.onUserMessage("hi")
runCurrent()
val now = kotlin.time.Clock.System.now()
conv.emit(Event.StartReasoning(now))
runCurrent()
val sysMsgs = state.messages.value.filterIsInstance<TuiMessage.System>()
assertTrue(sysMsgs.any { it.text.contains("думаю") })
}
}
private fun List<List<Content>>.flattenText(): List<String> =
map { cs -> cs.filterIsInstance<Content.Text>().joinToString("") { it.body } }
@@ -0,0 +1,12 @@
package pw.binom.agentik.tui
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent
/**
* Платформенные actual'ы для JVM. Используется `:client` поверх Ktor CIO.
*/
internal actual fun platformEnv(key: String): String? = System.getenv(key)
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
AgentikAgent(id = id, baseUrl = baseUrl)
@@ -0,0 +1,13 @@
package pw.binom.agentik.tui
import pw.binom.agentik.proto.Agent
/**
* Заглушка для native-целей: TUI на нативе пока не работает — нужно подключить
* ktor-client-* движки и termios. Нативный бинарь собирается, но main() падает
* с понятной ошибкой.
*/
internal actual fun platformEnv(key: String): String? = null
internal actual fun platformCreateAgent(baseUrl: String, id: String): Agent =
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
+134 -1
View File
@@ -2,7 +2,140 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
// maven-publish — стандартный плагин Gradle, объявлен apply'ем в subprojects ниже.
}
group = "pw.binom.agentik"
version = "0.1.0"
// projectVersion определяется ниже как val, чтобы subprojects могли его
// прочитать через rootProject.extra["projectVersion"].
// Publication version: -Pversion=<tag> (CICD publishes by release tag) с
// fallback в gradle.properties (ключ `agentik.version.default`, не `version`
// — иначе Gradle-мерж gradle.properties и -Pversion= отдаёт приоритет
// gradle.properties). Без версии maven-publish падает с
// "Invalid publication 'kotlinMultiplatform': version cannot be empty" —
// это известный gotcha: subprojects читают rootProject.version ДО того, как
// if-блок ниже успевает его установить. Фикс: provider+orElse вычисляется
// eagerly, и subprojects получают готовую строку.
val projectVersion: String = providers.gradleProperty("version")
.map { it.trimStart('v', 'V') } // strip optional "v" prefix from tag
.getOrElse(providers.gradleProperty("agentik.version.default").orElse("0.1.0-SNAPSHOT").get())
version = projectVersion
extra["projectVersion"] = projectVersion
// Home Nexus URL/creds — передаются через -Pbinom.repo.* из CI/CD workflow
// (.gitea/workflows/release.yml). Локально для дебага:
// ./gradlew publish -Pbinom.repo.url=http://... -Pbinom.repo.user=... -Pbinom.repo.password=...
// Без -P URL падает на дефолтный placeholder (заглушка для локальной разработки).
val binomRepoUrl = (findProperty("binom.repo.url") as String? ?: "http://nexus.xx/repository/caffeine/").toString()
val binomRepoUser = (findProperty("binom.repo.user") as String? ?: "").toString()
val binomRepoPassword = (findProperty("binom.repo.password") as String? ?: "").toString()
// Per-module POM description. Один источник истины — карта ниже,
// лишнее в settings.gradle.kts держим в комментарии-зеркале.
// При добавлении нового модуля — добавь строку сюда + README.md в его корень.
// Кладём в rootProject.extra ДО apply KMP-плагина в subprojects (beforeEvaluate
// срабатывает позже, чем apply плагина, поэтому просто положить extra в
// beforeEvaluate — поздно).
val moduleDescriptions: Map<String, String> = mapOf(
"proto" to "agentik :proto — stateful KMP protocol (Agent/Conversation/Message/Event) replacing AG-UI; типы и контракт без сетевой логики.",
"skills" to "agentik :skills — парсер opencode-style SKILL.md / *.yaml (YAML-frontmatter + markdown body); загружается в system prompt.",
"server" to "agentik :server — Ktor-фасад, экспонирующий Agent по HTTP+JSON+SSE под путём /agentik.",
"client" to "agentik :client — Ktor-клиент (HTTP+JSON+SSE), превращающий /agentik в Agent/Conversation из :proto.",
"memory-api" to "agentik :memory-api — интерфейсы долговременной памяти (MemoryStore, MemoryCategory, MemoryNote).",
"memory-md" to "agentik :memory-md — Hermes-style реализация памяти поверх §-файлов (user/world/preference.md).",
"memory-vector" to "agentik :memory-vector — ANN+JVector+SQLite реализация памяти с эмбеддингами (HTTP/SIGLIP).",
"storage-core" to "agentik :storage-core — интерфейсы хранилища (MessageStore/WorkingMemoryStore/ConversationStore/ReflectionStore).",
"storage-inmemory" to "agentik :storage-inmemory — in-memory реализация всех сторов из :storage-core (для тестов и Android).",
"storage-sqlite" to "agentik :storage-sqlite — SQLDelight реализация всех сторов на SQLite (прод-бэкенд).",
"agent-toolsets" to "agentik :agent-toolsets — реестр инструментов + диспетчер тулов (enable_toolset/disable_toolset); переиспользуемое ядро.",
"agentik-cli" to "agentik :agentik-cli — JVM CLI-клиент (JLine) к /agentik: REPL + slash-команды + стрим SSE.",
// "agentik-tui" to "agentik :agentik-tui — Compose-for-Mosaic TUI-клиент (отключён 2026-09-17)."
"standalone" to "agentik :standalone — single-jar HTTP-сервер со всеми транспортами (AG-UI/A2A/:proto), SQLite, памятью, скилами и SOUL.",
)
rootProject.extra.set("moduleDescriptions", moduleDescriptions)
subprojects {
group = rootProject.group
// KMP-плагин читает project.version на ранней стадии evaluation — ДО того
// как сработает внешний subprojects-блок. Если version ещё "unspecified",
// publication 'kotlinMultiplatform' создаётся с пустой version, и тогда
// maven-publish падает с 'InvalidMavenPublicationException: version cannot
// be empty'. Поэтому:
// 1) eagerly переопределяем version в rootProject.extra (см. выше)
// 2) на КАЖДЫЙ subproject вешаем beforeEvaluate, который выставляет
// version до того, как KMP-плагин начнёт создавать publications.
// per-module POM description берётся из rootProject.extra["moduleDescriptions"]
// (см. корень build.gradle.kts); добавлять новый модуль — туда + README.md.
// beforeEvaluate срабатывает ДО apply плагинов в build.gradle.kts модуля, так
// что version/description уже валидны, когда KMP-плагин начинает создавать
// publications.
beforeEvaluate {
description = (rootProject.extra["moduleDescriptions"] as Map<String, String>)[project.name]
?: "agentik module: ${project.name}"
version = rootProject.extra["projectVersion"] as String
}
apply(plugin = "maven-publish")
extensions.configure<PublishingExtension>("publishing") {
repositories {
maven {
name = "caffeine"
url = uri(binomRepoUrl)
isAllowInsecureProtocol = true
credentials {
username = binomRepoUser
password = binomRepoPassword
}
}
}
// Per-subproject POM-метаданные (name, scm, licenses, developers).
// Также явно выставляем version/group для каждой публикации. В KMP-модулях
// (особенно JVM-only с одним jvm() target) kotlin-multiplatform plugin
// создаёт publication 'kotlinMultiplatform' на ранней стадии evaluation,
// когда project.version ещё 'unspecified'. Простое присваивание
// subprojects { version = ... } НЕ перезаписывает уже зафиксированную
// version в publication → InvalidMavenPublicationException в CI.
// Явная установка version здесь гарантирует, что публикация всегда
// использует актуальное значение из rootProject.extra.
publications.withType<MavenPublication>().configureEach {
groupId = rootProject.group.toString()
artifactId = project.name
version = rootProject.extra["projectVersion"] as String
pom {
name = project.name
description = (rootProject.extra["moduleDescriptions"] as? Map<String, String>)?.get(project.name)
?: "agentik module: ${project.name}"
url = "https://git.binom.pw/subochev/agentik"
licenses {
license {
name = "Apache-2.0"
url = "https://www.apache.org/licenses/LICENSE-2.0"
}
}
developers {
developer {
id = "subochev"
name = "Subochev Alexey"
url = "https://git.binom.pw/subochev"
}
}
scm {
connection = "scm:git:https://git.binom.pw/subochev/agentik.git"
developerConnection = "scm:git:ssh://git@git.binom.pw/subochev/agentik.git"
url = "https://git.binom.pw/subochev/agentik"
}
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
# `:client` — Ktor-клиент к `:server`/`:proto` (KMP, jvm + native)
## Что это
Ktor client (`io.ktor.client.HttpClient` + `ContentNegotiation(json) +
Sse`), превращающий HTTP/SSE-фасад `:server` в `Agent`/`Conversation`
интерфейсы `:proto`:
- `AgentikAgent(id, baseUrl)` — entry-point фабрики.
- `AgentClient` — список и lifecycle диалогов.
- `ConversationClient` — `send()`, `events()`, `interrupt()`,
`getMessages()`, `rename()`, `close()`.
- Внутренний парсер SSE → `Flow<Event>`.
Решает: пишем нативный Kotlin-клиент, без curl/JS/Python boilerplate,
с теми же типами, что и сервер. Один и тот же клиент работает на
JVM, iOS, macOS, Linux, Windows.
## Где используется
- `:agentik-cli` — REPL.
- `:agentik-cli` — JVM/native CLI-клиент поверх `:client`.
- Любой внешний KMP-проект, который хочет встроить агента в свой UI.
## Как подключить
```kotlin
// build.gradle.kts
kotlin {
sourceSets.commonMain.dependencies {
api("pw.binom.agentik:client:0.1.0")
}
}
// ваш код:
val agent = AgentikAgent(id = "agentik", baseUrl = "http://192.168.76.166:8080/agentik")
val conv = agent.createConversation(title = "test")
conv.send(listOf(Content.Text("hello"))).collect { event ->
when (event) {
is Event.AppendText -> print(event.body)
is Event.End -> println("\n--- end ---")
is Event.Error -> error("agent error: ${event.message}")
else -> Unit
}
}
```
## Версии
`gradle/libs.versions.toml` → `[versions] agentik-client`.
Поддерживает все KMP-таргеты, что и `:proto`.
## Примеры API
```kotlin
// список диалогов
agent.getConversations().collect { println(it.id to it.title) }
// live-подписка на события отдельного диалога
val sub = conversation.events(after = Instant.parse("2026-09-01T00:00:00Z")).collect { }
// прерывание текущего хода
conversation.interrupt()
// история
conversation.getMessages(offset = 0).collect { msg ->
when (msg) {
is Message.UserMessage -> println("user: ${msg.content}")
is Message.AssistantMessage -> println("assistant: ${msg.content}")
else -> Unit
}
}
```
## Тесты
```
./gradlew :client:jvmTest
```
Покрывают: JSON-парсинг Event'ов, SSE-стрим, recovery после разрыва,
401/404.
## Чего здесь НЕТ
- Никакого LLM-кода. Это просто клиент.
- Никакого persistent state. История хранится у сервера, клиент её
запрашивает через `getMessages` или подписывается через `events`.
## Текущий статус
Используется продакшеном. Бэкендом служит `:server` поверх `:standalone`,
но клиент совместим с любым сервером, который держит wire-контракт
`:server`.
## Известное ограничение
SSE event-stream в не-TTY ssh-сессии (без `-tt`) закрывается на
default-таймауте Ktor. Используйте либо ssh -tt, либо нативный
terminal (TTY). Это upstream-особенность Ktor SSE.
+35 -9
View File
@@ -1,18 +1,26 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
jvmToolchain(21)
dependencies {
implementation(project(":proto"))
// Только то, что нам реально нужно: JVM + 5 desktop-native. iOS не входит —
// :client не имеет смысла на iOS, а :agentik-cli использует :client и тоже
// без iOS. См. agentik-cli/build.gradle.kts.
jvm()
listOf(
macosX64(),
macosArm64(),
linuxX64(),
linuxArm64(),
mingwX64(),
)
sourceSets {
commonMain.dependencies {
api(project(":proto"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
@@ -20,5 +28,23 @@ dependencies {
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.test.host)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.sse)
}
jvmTest.dependencies {
implementation("junit:junit:4.13.2")
}
}
// :client — это библиотека, не executable. Native-бинари объявляются
// в :agentik-cli (он зависит от :client и реально предоставляет main).
}
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
@@ -66,7 +67,8 @@ internal class AgentClient(
}
override fun events(after: Instant): Flow<AgentEvent> = flow {
val response = httpClient.get("$agentUrl/events?after=$after")
httpClient.prepareGet("$agentUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
@@ -76,3 +78,4 @@ internal class AgentClient(
}
}
}
}
@@ -1,9 +1,6 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import pw.binom.agentik.proto.Agent
/**
@@ -24,8 +21,8 @@ import pw.binom.agentik.proto.Agent
* агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига).
*
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (платформо-зависимый
* движок: CIO на JVM, libcurl на desktop-native). Можно передать свой.
*/
fun AgentikAgent(
id: String,
@@ -34,10 +31,16 @@ fun AgentikAgent(
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
/**
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
* (см. [readSse]) живёт в общем коде и плагина не требует.
* Дефолтный [HttpClient] для общения с `agentikAgent`. SSE-парсер ([readSse])
* живёт в общем коде и плагина `SSEClientContent` не требует.
*
* **Платформы:**
* - JVM: движок CIO. `engine { requestTimeout = 0 }` отключает встроенный
* 15-секундный request-таймаут движка (наш кастомный SSE-ридер не маркирует
* для долгих idle-стримов). Defense-in-depth: SSE-запросы в
* `ConversationClient.events`/`AgentClient.events` уже ставят
* `HttpTimeoutCapability` = INFINITE (см. [noSseReadTimeout]).
*
* Один движок CIO работает и на JVM, и на всех desktop-native (linux/macos/mingw).
* Реализация — в [HttpClientFactory.kt].
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(agentikJson) }
}
@@ -6,6 +6,7 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
@@ -13,10 +14,12 @@ 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
/**
@@ -58,10 +61,10 @@ internal class ConversationClient(
snapshot = updated
}
override suspend fun send(content: List<Content>) {
override suspend fun send(content: List<Content>, context: MessageContext?) {
httpClient.post("$convUrl/messages") {
contentType(ContentType.Application.Json)
setBody(content)
setBody(SendPayload(content, context))
}
}
@@ -70,7 +73,11 @@ internal class ConversationClient(
}
override fun events(after: Instant): Flow<Event> = flow {
val response = httpClient.get("$convUrl/events?after=$after")
// prepareGet + execute (а не get) обязателен: `get` дожидается полного
// тела ответа, а SSE-поток не заканчивается никогда — вызов висел бы
// вечно. `execute` отдаёт HttpResponse со стриминговым bodyAsChannel.
httpClient.prepareGet("$convUrl/events?after=$after") { noSseReadTimeout() }
.execute { response ->
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
@@ -79,6 +86,7 @@ internal class ConversationClient(
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
}
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
httpClient.get("$convUrl/messages") {
@@ -92,3 +100,9 @@ internal class ConversationClient(
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
}
}
@Serializable
private data class SendPayload(
val content: List<Content>,
val context: MessageContext? = null,
)
@@ -0,0 +1,18 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
/**
* Единый HTTP-клиент для JVM и всех 5 native-таргетов (:agentik-cli).
* CIO в ktor 3.x — KMP, поддерживает linuxX64/Arm64, macosX64/Arm64, mingwX64.
*
* `requestTimeout = 0` — defense-in-depth против read-таймаута на SSE:
* основная защита в `HttpRequestBuilder.noSseReadTimeout()` ([SseTimeout]).
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
engine { requestTimeout = 0 }
install(ContentNegotiation) { json(agentikJson) }
}
@@ -15,8 +15,11 @@ import kotlin.time.Instant
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
*/
internal object InstantSerializer : KSerializer<Instant> {
// Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе
// kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить
// оба варианта (нативный сериализатор Instant + наш custom) в одном процессе.
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
@@ -0,0 +1,31 @@
package pw.binom.agentik.client
import io.ktor.client.plugins.HttpTimeoutConfig
import io.ktor.client.plugins.HttpTimeoutCapability
import io.ktor.client.request.HttpRequestBuilder
/**
* Отключает request/connect/socket-таймауты для конкретного запроса через
* [HttpTimeoutCapability] со всеми таймаутами = [HttpTimeoutConfig.INFINITE_TIMEOUT_MS].
*
* Зачем: наш SSE-ридер ([readSse]) читает `bodyAsChannel()` руками и не
* использует плагин `SSE`, поэтому движок не считает запрос SSE-шным
* (`HttpRequestBuilder.supportsRequestTimeout` проверяет
* `body is SSEClientContent`, а у нас тело — обычный GET без тела).
* Без capability встроенный `CIOEngineConfig.requestTimeout` (по умолчанию
* **15000 мс**) молча убивает долгий idle-стрим через 15 секунд.
*
* Конфиг создаётся заново на каждый вызов — плагин `HttpTimeout` при
* установленном capability мутирует его поля через `?:`, так что шаренный
* инстанс мог бы утечь между запросами.
*/
internal fun HttpRequestBuilder.noSseReadTimeout() {
setCapability(
HttpTimeoutCapability,
HttpTimeoutConfig(
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS,
),
)
}
@@ -0,0 +1,148 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.request.header
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondBytesWriter
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.http.ContentType
import io.ktor.utils.io.writeStringUtf8
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.net.ServerSocket
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Репродукция бага Ktor CIO: дефолтный [io.ktor.client.engine.cio.CIOEngineConfig.requestTimeout]
* = 15 с убивает SSE read. Наш fix — [noSseReadTimeout] ставит capability
* [io.ktor.client.plugins.HttpTimeoutCapability] со всеми таймаутами = INFINITE
* перед каждым read-стримом.
*
* Тест запускает встроенный Ktor CIO-сервер на свободном порту. Сервер шлёт
* "hello", ждёт 20 с (дольше дефолтного requestTimeout = 15 с), затем шлёт
* "done". Без capability клиент отвалился бы на ~15 с; с capability — второе
* сообщение доходит.
*
* Читаем строки пока не найдём "data: done" или пока не сработает
* [withTimeout] (18 с — запас над server delay 20 с).
*/
class SseTimeoutTest {
private fun freePort(): Int = ServerSocket(0).use { it.localPort }
@Test
fun `sse read survives past default cio timeout with noSseReadTimeout`(): Unit = runBlocking {
val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing {
get("/sse") {
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
writeStringUtf8("data: hello\n\n")
flush()
// 17 с — чуть больше дефолтного CIO requestTimeout = 15 с.
// Если capability сломана, клиент упадёт на 15 с и не получит "done".
delay(17_000)
writeStringUtf8("data: done\n\n")
}
}
}
}.start(wait = false)
try {
val client = HttpClient(CIO)
val received = mutableListOf<String>()
client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream")
noSseReadTimeout()
}.execute { resp ->
val ch = resp.bodyAsChannel()
// 19 с запас: ждём, пока сервер пошлёт "done" после 17 с.
// Если capability сломана, клиент упадёт на 15 с и мы словим исключение.
val deadline = 19_000L
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < deadline) {
val line = withTimeout<String?>(deadline) { ch.readUTF8Line() } ?: break
if (line.startsWith("data: ")) {
received.add(line)
}
if (line == "data: done") break
}
}
assertTrue(received.contains("data: hello"), "должно получить hello: $received")
assertTrue(
received.contains("data: done"),
"должно получить done (SSE read не должен падать на 15 с): $received",
)
assertFalse(
received.any { it == "<timeout>" },
"SSE read упал в timeout (capability не сработал): $received",
)
} finally {
server.stop(100, 200)
}
}
/**
* Контр-тест: убеждаемся что БЕЗ [noSseReadTimeout] дефолтный
* CIO requestTimeout = 15 с действительно убивает SSE-стрим.
* Сервер держит stream 17 с; если клиент не выставил capability —
* мы должны получить [HttpRequestTimeoutException] на ~15 с, не
* дожидаясь "done".
*/
@Test
fun `without noSseReadTimeout default cio requestTimeout kills the stream`(): Unit = runBlocking {
val port = freePort()
val server = embeddedServer(io.ktor.server.cio.CIO, port = port) {
routing {
get("/sse") {
call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
writeStringUtf8("data: hello\n\n")
flush()
delay(17_000)
writeStringUtf8("data: done\n\n")
}
}
}
}.start(wait = false)
try {
val client = HttpClient(CIO)
val start = System.currentTimeMillis()
try {
client.prepareGet("http://127.0.0.1:$port/sse") {
header("Accept", "text/event-stream")
// НАМЕРЕННО без noSseReadTimeout.
}.execute { resp ->
val ch = resp.bodyAsChannel()
// Читаем строки, пока не придёт "data: done" — без capability
// клиент упадёт на ~15 с до того, как сервер пошлёт done.
while (true) {
val line = ch.readUTF8Line() ?: break
if (line == "data: done") break
}
}
fail("без capability клиент должен словить HttpRequestTimeoutException")
} catch (e: HttpRequestTimeoutException) {
val elapsed = System.currentTimeMillis() - start
assertTrue(
elapsed in 14_000..17_000,
"timeout должен сработать в районе 15 с (default), elapsed=$elapsed",
)
}
} finally {
server.stop(100, 200)
}
}
}
+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.
+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.
+158
View File
@@ -0,0 +1,158 @@
# 01 — Слои модулей (целевое состояние)
Целевая модульная структура agentik. Снизу вверх:
**приложения → runtime → домен → абстракции → платформенные impl**.
![Module Layers](./01-module-layers.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml agentik-module-layers
skinparam componentStyle rectangle
skinparam ranksep 60
skinparam nodesep 30
skinparam packageStyle rectangle
title agentik — слои модулей (целевое состояние)
' --- Applications: entry points (thin wrappers) ---
package "Applications\n(entry points, тонкие)" {
[Standalone\nHTTP+AG-UI+A2A] as Standalone
[AgentikCli\nREPL] as Cli
[AgentikAndroid\nCompose UI] as Android
}
' --- Agent runtime ---
package "Agent Runtime\n(композиция, lifecycle)" {
[AgentCore\nBaseAgent] as AgentCore
[AgentBuilder\nDSL] as Builder
}
' --- Background work ---
package "Background Work\n(event-driven triggers)" {
[BackgroundEvents\nbus + events] as Ev
[BackgroundScheduler\npolicy] as Sched
}
' --- Domain logic (generic, переиспользуется) ---
package "Domain Logic\n(generic tools)" {
[LlmTools\nReflector/Reviewer/Miner] as LlmT
[McpBridge\nMCP-SDK → LiteTool] as Mcp
[Skills\nparse + store] as Skills
}
' --- Storage abstractions + impls ---
package "Storage\n(abstractions)" as StoragePkg {
[StorageCore\ninterfaces] as StorageCore
}
package "Storage\n(JVM impls)" {
[StorageSqlite\nJDBC] as StorageSql
[StorageInmemory\ntests] as StorageInmem
}
package "Storage\n(Android impl)" {
[StorageSqliteAndroid\nRoom/sqlite] as StorageSqlA
}
' --- Memory backends ---
package "Memory\n(abstractions)" {
[MemoryApi\nMemorySystem/MemoryTools] as MemApi
}
package "Memory\n(impls)" {
[MemoryMd\nHermes §-files] as MemMd
[MemoryVector\nJVector+JVM] as MemVec
[MemoryVectorAndroid\nONNX+ANN] as MemVecA
}
' --- LLM backends ---
package "LLM\n(abstractions)" {
[LitertApi\nLiteLlm контракт] as Litert
}
package "LLM\n(impls)" {
[LitertOpenai\nHTTP] as LitertO
[LitertGoogle\nLiteRT JVM] as LitertG
[LitertAndroid\nLiteRT Android] as LitertA
}
' --- Inter-app protocol ---
package "Inter-app" {
[Proto\nAgent/Conversation] as Proto
[A2AServer] as A2A
}
' --- Зависимости (приложения → runtime → домен → абстракции → платформенные импл) ---
Standalone ..> Builder
Cli ..> Builder
Android ..> Builder
Builder ..> AgentCore
AgentCore ..> Proto
AgentCore ..> StorageCore
AgentCore ..> MemApi
AgentCore ..> Litert
AgentCore ..> Mcp
AgentCore ..> Skills
Sched ..> Ev
AgentCore ..> Sched
AgentCore ..> Ev
Mcp ..> Litert
LlmT ..> Litert
MemMd ..> MemApi
MemVec ..> MemApi
MemVecA ..> MemApi
StorageSql ..> StorageCore
StorageInmem ..> StorageCore
StorageSqlA ..> StorageCore
LitertO ..> Litert
LitertG ..> Litert
LitertA ..> Litert
Standalone ..> A2A
Standalone ..> LitertO
Standalone ..> LitertG
Standalone ..> StorageSql
Standalone ..> MemMd
Standalone ..> MemVec
Standalone ..> Mcp
Android ..> LitertA
Android ..> StorageSqlA
Android ..> MemMd
Android ..> MemVecA
@enduml
```
## Что показывает
- **Applications** — три точки входа: web-сервер, CLI REPL, Android-приложение. Каждое тонкое, не содержит бизнес-логики.
- **Agent Runtime** — `BaseAgent` + `AgentBuilder` DSL. Вся композиция и lifecycle.
- **Background Work** — `BackgroundEvents` (event-bus) + `BackgroundScheduler` (policy подписки). Event-driven, не interval-polling.
- **Domain Logic** — generic переиспользуемые модули (`:llm-tools`, `:mcp-bridge`, `:skills`).
- **Storage / Memory / LLM** — каждая с абстракцией и одним или несколькими impl (JVM-only или Android-only).
- **Inter-app** — `:proto` контракты + `:a2a-server` для межагентного общения.
## Текущее состояние vs целевое
✅ Уже сделано (в этом цикле правок):
- `:llm-tools` extracted
- `:mcp-bridge` extracted
- `BackgroundScheduler` стал event-driven
- `ConversationLoop` стал отдельным компонентом (typealias `ChatConversation`)
⏳ Не сделано:
- `:agent-core` (выделить `BaseAgent` + builder в отдельный KMP-модуль)
- `:background-events` (выделить events + scheduler — пока в `:standalone`)
- `:storage-sqlite-android`
- `:memory-vector-android`
- `:litert-android`
- `:agentik-android` (само приложение)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

+127
View File
@@ -0,0 +1,127 @@
# 02 — Agent Builder: композиция (целевое API)
Как `AgentBuilder` собирает `BaseAgent` из компонентов. **Memory backend сам объявляет свои tools** — builder их авто-мержит. BackgroundScheduler подписан на события, не interval-poll.
![Agent Composition](./02-agent-composition.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml agent-composition
skinparam componentStyle rectangle
title Agent Builder — композиция (целевое API)
' --- Builder ---
rectangle "AgentBuilder" as Builder {
rectangle "llm: LiteLlm (обязательно)" as Llm
rectangle "storage: StorageBundle (обязательно)" as Storage
rectangle "memory: MemorySystem (обязательно)" as Mem
rectangle "soul: SoulProvider (default NoopSoul)" as Soul
rectangle "tools: List<NamedTool> (авто-сборка из backends)" as Tools
rectangle "background: BackgroundConfig (default EmptyBg)" as Bg
rectangle "toolset: List<ToolsetContribution> (default empty)" as Ts
}
' --- Backends with their tool side-effects ---
rectangle "MemoryMd" as MdMem {
interface "MemorySystem" as MemSys
interface "List<NamedTool>" as MdTools
note right
MemoryMd.exposesTools() →
memory_save / memory_read /
memory_list / memory_delete
end note
}
rectangle "McpRegistry" as McpReg {
interface "List<NamedTool>" as McpTools
note right
McpRegistry.namedTools →
server__tool1, server__tool2,
...
end note
}
rectangle "BackgroundEvents" as Events {
interface "MutableSharedFlow<CompactionEvent|ToolCallEvent|LifecycleEvent>" as Flow
note right
Эмитится из:
- CompactionCoordinator
- ToolDispatcher
- ConversationLoop.close()
end note
}
rectangle "BackgroundScheduler" as Sched {
interface "policy: trigger + debounce" as Policy
note right
Подписан на Events.
НИКАКОГО interval-polling.
end note
}
' --- Получаемый Agent ---
rectangle "BaseAgent\n(impl: ConversationLoop)" as Agent {
rectangle "send / interrupt / events" as API
rectangle "BackgroundScheduler\nподписка" as Sub
}
' --- Стрелки зависимостей ---
Builder --> Llm
Builder --> Storage
Builder --> Mem
Builder --> Soul
Builder --> Tools
Builder --> Bg
Builder --> Ts
MdMem --> Mem : implements
MdMem --> MdTools : exposes
McpReg --> McpTools : exposes
Bg --> Events : subscribes-to
Bg --> Sched : holds
Tools <-- MdTools : auto-merge
Tools <-- McpTools : auto-merge
Builder --> Agent : build()
Agent --> API
Agent --> Sub
@enduml
```
## Целевой Kotlin DSL
```kotlin
val agent = agentBuilder {
// Обязательные
llm(OpenAiLlm.fromEnv()) // или LitertAndroid.onDevice(context)
storage(SqliteStorage(path)) // или SqliteStorage.android(context)
memory(MemoryMd(root = "~/memory")) // или MemoryVector(embedding = HttpEmbedding(...))
// Опциональные
soul(FileSoul("~/SOUL.md")) // или HttpSoul(url), NoopSoul()
background {
// triggers: OnClosing (reflection+mining), OnCompaction(minTurns=10, mining=true)
// event-driven, не interval
}
tools {
// memoryMd.exposesTools() + mcpRegistry.namedTools авто-подцепляются
+FileReadTool(root = "/data")
}
toolset {
+MemoryToolsToolset(memoryMd)
}
}.build()
```
## Ключевые решения
- **`MemoryBackend.exposesTools()`** — backend декларирует свои tools. Не «подставить любой backend», а «backend сообщает что он умеет». Это убирает coupling «какие tools совместимы с какими backends».
- **Builder требует только `llm + storage + memory`** как обязательные. Всё остальное — опционально с разумными default'ами (`NoopSoul`, `EmptyBackground`, `empty toolset`).
- **`BaseAgent`** — реализация `ConversationLoop` через builder. Конструктор принимает все нужные компоненты. **Один и тот же `BaseAgent` в `:standalone`, `:agentik-cli`, `:agentik-android`** — отличается только wiring через builder.
- **BackgroundScheduler подписан на `BackgroundEvents`** — это даёт event-driven по умолчанию. `OnEvery(n)` interval-режим — опциональный fallback (не default).
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

+129
View File
@@ -0,0 +1,129 @@
# 03 — Multi-user chat с mention-detection
Точка 1 из планов. Один `BaseAgent` обслуживает N пользователей. Отвечает только когда addressed (mention или admin-команда).
![Multi-user chat](./03-multi-user-chat.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml multi-user-chat
skinparam componentStyle rectangle
skinparam participantPadding 15
skinparam boxPadding 10
title Multi-user chat с mention-detection (точка 1 из планов)
' --- Участники ---
actor "User A" as UA
actor "User B" as UB
actor "User C\n(админ)" as UC
participant "Telegram /\nSlack /\nMatrix" as Channel
participant "AgentRuntime\n(BaseAgent)" as Runtime
participant "MentionDetector" as Detector
participant "SoulProvider" as Soul
participant "MemorySystem\n(MdMemory)" as Memory
participant "LlmBackend\n(LiteLlm)" as Llm
' --- Сценарий ---
UA -> Channel : "@bot, что нового?"
UB -> Channel : "люблю котов"
UC -> Channel : "/bot status"
Channel -> Runtime : событие чата
' --- Внутри Runtime ---
Runtime -> Detector : isMentioned(message, botName)
note right of Detector
variants:
- SimpleMentionDetector (regex: @bot)
- LlmMentionDetector (mini-classifier)
- AdminCommandDetector (/command)
end note
Detector --> Runtime : MatchResult{isMentioned, isCommand}
alt isMentioned или isCommand
Runtime -> Soul : read()
Runtime -> Memory : prefetch(query, topK)
Runtime -> Llm : send(system + history + memory + user)
Llm --> Runtime : response + tool_calls
Runtime -> Memory : save(decision)
Runtime --> Channel : ответ в нужный канал/thread
else NOT mentioned и NOT command
Runtime -> Runtime : drop (если не админ)
note right
Не отвечаем, но возможно:
- запоминаем факт (memory-only update)
- summary на long conversation
end note
end
@enduml
```
## Ключевые модули (что нужно будет добавить)
### `MentionDetector` interface
```kotlin
interface MentionDetector {
data class Result(
val isMentioned: Boolean,
val isAdminCommand: Boolean,
val isPrivateMessage: Boolean, // DM — всегда отвечаем
)
fun detect(message: ChatMessage, botName: String): Result
}
```
Имплементации:
- `SimpleMentionDetector` — regex `@bot`, `/command` (дешёво, latency ~0)
- `LlmMentionDetector` — маленькая классификация через тот же LLM (точнее, но +1 LLM-вызов на каждое сообщение)
- `HybridMentionDetector` — fast regex → fallback на LLM только если ambiguous
### `ChatAdapter` interface
```kotlin
interface ChatAdapter {
val channel: String // "telegram" / "slack" / "matrix"
suspend fun listen(onMessage: (ChatMessage) -> Unit): Job
suspend fun reply(messageId: String, text: String, threadId: String? = null)
suspend fun isAdmin(userId: String): Boolean
}
```
Имплементации per platform. Каждая адаптирует формат platform → `ChatMessage`.
### Конфигурация builder'а
```kotlin
agentBuilder {
llm(...)
storage(...)
memory(...)
soul(...)
background { ... }
chat {
mentionDetector = HybridMentionDetector(regex = "@bot|@Agent", llmClassifier = false)
chatAdapter = TelegramChatAdapter(token = "...")
// На каждое сообщение:
// 1. mentionDetector.detect()
// 2. если isMentioned || isAdminCommand || isPrivate → process
// 3. иначе — опционально memory-only save (тихий режим)
}
}
```
## Что это даёт
- Один `BaseAgent` обслуживает чат целиком (один LLM, одна память — общий контекст команды)
- `@bot` — explicit invocation, не «agent отвечает на всё подряд»
- `/bot status` / `/bot clear-memory` — admin-команды (отдельный канал, без LLM)
- DM — всегда отвечает (это личное обращение)
- В групповом чате без mention — agent может **молча учить** (memory update без ответа). Полезно для «запомнил что Вася любит котов».
## Текущее состояние vs целевое
⏳ Ничего из этого нет. Сейчас `:standalone` — это HTTP API, к которому подключаются clients. Для multi-user chat нужен новый `:chat-adapter-telegram` (или -slack / -matrix) модуль + `MentionDetector` interface.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

+119
View File
@@ -0,0 +1,119 @@
# 04 — Sub-agents + A2A между агентами
Точки 2 и 3 из планов. Orchestrator-агент spawn'ит sub-агентов с изолированным контекстом. Независимые агенты общаются через A2A.
![Sub-agents + A2A](./04-sub-agents.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml sub-agents-and-a2a
skinparam componentStyle rectangle
title Sub-agents + A2A между агентами (точка 2+3 из планов)
' --- Orchestrator ---
rectangle "OrchestratorAgent\n(BaseAgent + tools)" as Orch {
rectangle "ConversationLoop\n(main user)" as MainConv
}
' --- Sub-agent spawn ---
rectangle "subAgent(\n task: String,\n config: AgentConfig\n): Flow<SubAgentEvent>" as SpawnAPI
note right of SpawnAPI
Spawn API — НЕ отдельный модуль,
а convenience поверх BaseAgent:
val sub = agent.spawnChild(config) {
systemPrompt = "..."
tools = [ReadTool, WriteTool]
memory = EmptyMemory // изолированно
}
sub.events.collect { ... }
end note
' --- Дочерний агент (изолированный контекст) ---
rectangle "SubAgent\n(изолированный scope)" as Sub {
rectangle "ConversationLoop\n(child)" as SubConv
rectangle "backgroundScope\n(lifecycle scoped)" as SubBg
}
' --- A2A между независимыми агентами ---
rectangle "Agent A\n(BaseAgent)" as AgentA
rectangle "Agent B\n(BaseAgent)" as AgentB
rectangle "A2A Server\n(:a2a-server)" as A2ASrv
AgentA -> A2ASrv : POST /\n(application/json)
A2ASrv -> AgentB : dispatch(message)
AgentB --> A2ASrv : response
A2ASrv --> AgentA : SSE / JSON-RPC
' --- Стрелки ---
Orch -> SpawnAPI : calls
SpawnAPI -> Sub : creates with custom config
Sub -> SubBg : has its own
Orch -> Orch : main flow continues
Sub --> Orch : Flow<SubAgentEvent> emits\n(Started / ToolCalled / ToolResult /\nAssistantMessage / Done / Failed)
Orch -> A2ASrv : can also delegate to remote agent
@enduml
```
## Sub-agents API
```kotlin
sealed interface SubAgentEvent {
data class Started(val taskId: String) : SubAgentEvent
data class AssistantMessage(val text: String) : SubAgentEvent
data class ToolCalled(val toolName: String, val args: JsonObject) : SubAgentEvent
data class ToolResult(val toolName: String, val result: String) : SubAgentEvent
data class Done(val taskId: String, val finalResult: String) : SubAgentEvent
data class Failed(val taskId: String, val error: String) : SubAgentEvent
}
interface BaseAgent {
// ... existing methods ...
/**
* Spawn дочерний агент с изолированным контекстом (memory, system prompt,
* tools). Возвращает Flow событий жизненного цикла + результата.
* Cancellation родителя НЕ отменяет sub-agent — sub-agent живёт до Done/Failed.
*/
fun spawnChild(config: SubAgentConfig): Flow<SubAgentEvent>
}
data class SubAgentConfig(
val systemPrompt: String,
val tools: List<NamedTool> = emptyList(),
val memory: MemorySystem = EmptyMemory(),
val model: LiteLlm? = null, // если null — делит LLM родителя
val maxTurns: Int = 10,
val timeoutMs: Long = 60_000,
)
```
## Зачем изолированный scope
Sub-agent получает **свою копию контекста**, не делит memory с родителем. Это критично:
- `research_subagent` — должен исследовать тему, не отвечать на основные сообщения пользователя
- `summarize_subagent` — суммаризировать документ, не трогать основной диалог
- `code_review_subagent` — ревьюить PR, не видеть разговор
Если нужно расшарить контекст — это explicit через `sharedMemory: SharedMemoryHandle` параметр, не default.
## A2A между независимыми агентами
Уже есть `:a2a-server` модуль (см. `standalone/build.gradle.kts` — `implementation(libs.a2a.server)`). Использовался для AG-UI/A2A протокола в `:standalone`. Можно переиспользовать для межагентного общения.
Сценарий: orchestrator-agent не может сам решить задачу → делегирует remote-агенту через A2A → получает response → продолжает. Это уже работающая инфраструктура.
## Текущее состояние vs целевое
✅ Уже есть:
- `:a2a-server` подключён
- `BaseAgent.spawnChild` — **не существует**, но `ConversationLoop` уже умеет создавать изолированный scope через свой `agentScope` — нужна только обёртка
⏳ Не сделано:
- `SubAgentConfig` + `Flow<SubAgentEvent>` API
- `EmptyMemory` (null-object для изолированного scope)
- Lifecycle management (parent dies → child должен complete or be cancelled?)
- Сериализация sub-agent state для отладки (event log)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

+171
View File
@@ -0,0 +1,171 @@
# 05 — Android Agent Stack
Что меняется vs `:standalone`. Цель: `BaseAgent` тот же самый, но platform-impl разные (Storage, LLM, Vector Memory, MCP).
![Android Agent Stack](./05-android-stack.svg)
PlantUML source (для редактирования; требует Graphviz `dot` для рендеринга):
```plantuml
@startuml android-agent-stack
skinparam componentStyle rectangle
title Android Agent Stack — что меняется vs Standalone
' --- Android side ---
package "Android Application" {
[MainActivity\n(Compose)] as Activity
[AndroidAgentRunner\n(workmanager / service)] as Runner
[AndroidAgentBuilder] as AndroidBuilder
}
package "Android-specific impls" {
[StorageSqliteAndroid\n(Room/sqlite)] as StorageA
[MemoryVectorAndroid\n(ONNX runtime + ANN)] as MemVecA
[LitertAndroid\n(NNAPI delegate)] as LitertA
[SoulFileAndroid\n(context.filesDir)] as SoulA
[McpRegistry\nstdio: ProcessBuilder] as McpA
}
' --- Shared (KMP) ---
package "Agent Runtime (shared)" {
[AgentCore\nBaseAgent] as AgentCore
[AgentBuilder] as Builder
}
package "Domain (shared)" {
[LlmTools\ncommonMain] as LlmT
[BackgroundEvents\ncommonMain] as Ev
[McpBridge\njvmMain] as McpB
[Skills\ncommonMain] as Skills
}
package "Memory (shared impl)" {
[MemoryMd\n(commonMain)] as MemMd
[MemoryApi\ninterfaces] as MemApi
}
' --- Зависимости ---
Activity --> Runner
Runner --> AndroidBuilder
AndroidBuilder --> AgentCore
AndroidBuilder --> StorageA
AndroidBuilder --> MemVecA
AndroidBuilder --> LitertA
AndroidBuilder --> SoulA
AndroidBuilder --> McpA
AgentCore --> LlmT
AgentCore --> Ev
AgentCore --> McpB
AgentCore --> Skills
AgentCore --> MemMd
' --- Главные отличия от Standalone ---
note right of LitertA
On-device inference.
LiteRT с NNAPI delegate →
работает на CPU/GPU/NPU
прямо на устройстве, без сети.
vs Standalone: HTTP-only
(OpenAI-compatible).
end note
note right of StorageA
android.database.sqlite
через Room или сырой API.
vs Standalone: JDBC +
Sqlite-JDBC driver
(только JVM).
end note
note right of MemVecA
JVector JVM-only. На Android
нужна альтернатива —
ONNX Runtime + какой-нибудь
ANN (Annoy/HNSW).
Или пока без vector memory,
только MemoryMd.
end note
note right of McpA
MCP через ProcessBuilder
на Android работает, но
subprocess lifecycle
сложнее (foreground service
нужен для долгого subprocess).
end note
@enduml
```
## Что общего с `:standalone`
**`BaseAgent`, `BackgroundScheduler`, `LlmTools`, `McpBridge`, `Skills`, `MemoryMd` — всё KMP (commonMain).** Android-agent = `:standalone` с другим wiring'ом. Не нужно переписывать agent logic.
## Что другое
| Компонент | `:standalone` (JVM) | `:agentik-android` (Android) | Сложность |
|---|---|---|---|
| Storage | `:storage-sqlite` (JDBC + Sqlite-JDBC) | `:storage-sqlite-android` (Room или raw) | Низкая — тот же `StorageBundle` interface |
| LLM | `:litert-openai` (HTTP), `:litert-google` (LiteRT JVM) | `:litert-android` (LiteRT Android, NNAPI delegate) | Средняя — нужен новый модуль |
| Vector memory | `:memory-vector` (JVector) | `:memory-vector-android` (ONNX Runtime + HNSW/Annoy) | Высокая — JVector JVM-only, нужна альтернатива |
| SOUL provider | `FileSoulProvider` (path) | `SoulFileAndroid` (`context.filesDir`) | Низкая |
| MCP | `McpRegistry` (ProcessBuilder, stdio subprocess) | Тот же `McpRegistry`, но subprocess в foreground service | Средняя — нужен Android service |
| Embedding | `HttpEmbeddingClient` (HTTP) | Тот же ИЛИ on-device (ONNX) | Средняя |
## Минимальный Android agent (v1)
Если не нужны все фичи сразу — минимум:
```kotlin
val agent = androidAgentBuilder(context) {
llm(LitertAndroid.onDevice(context, modelPath = "/data/local/tmp/model.litertlm"))
storage(SqliteStorage.android(context, "agent.db"))
memory(MemoryMd.root(context.filesDir.resolve("memory")))
soul(FileSoul(context.filesDir.resolve("SOUL.md")))
background {
// OnClosing + OnCompaction работают так же как на JVM
}
}
```
Без MCP, без vector memory (только MemoryMd на файлах), только on-device LLM. Достаточно для off-line агента.
## Foreground service для MCP
Если нужны MCP-серверы (например, локальный file-system MCP) — subprocess нужен foreground service чтобы Android не убил его при выключении экрана. Это добавляет сложности:
```kotlin
class McpForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, notification)
val proc = ProcessBuilder(command, args).start()
// ... route stdio to McpLiteToolAdapter ...
return START_STICKY
}
}
```
Пока можно без этого (только если MCP нужен на Android).
## Текущее состояние vs целевое
✅ KMP-ready:
- `:llm-tools` (commonMain, платформо-агностик)
- `:mcp-bridge` (jvmMain — Android-вариант через `:mcp-bridge-android`)
- `:skills` (commonMain)
- `:memory-md` (commonMain)
- `:proto` (commonMain)
⏳ Не существует:
- `:storage-sqlite-android`
- `:memory-vector-android`
- `:litert-android`
- `:agentik-android` (само приложение)
- `:agent-core` (выделить BaseAgent + builder)
- `:background-events` (выделить events + scheduler)
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

+64
View File
@@ -0,0 +1,64 @@
# agentik — диаграммы архитектуры
PlantUML-схемы для обсуждения будущей структуры (Android agent, multi-user chat, sub-agents, A2A). Это **целевое состояние**, не текущее.
## Файлы
Каждый `.md` содержит:
- Краткое описание (что показывает)
- **Пред-рендеренный SVG** (`![Diagram](file.svg)`) — гарантированно показывается **везде**
- PlantUML source в ` ```plantuml ` блоке — для редактирования (требует Graphviz `dot` для рендеринга)
- Дополнительный markdown-текст (что нужно сделать, текущее vs целевое)
| Файл | Что показывает |
|---|---|
| [01-module-layers.md](./01-module-layers.md) | Целевая модульная структура (приложения → runtime → домен → абстракции → платформенные impl). Что в каком слое и кто от кого зависит. |
| [02-agent-composition.md](./02-agent-composition.md) | Как `AgentBuilder` собирает `BaseAgent` из компонентов. Memory backend сам объявляет свои tools. BackgroundScheduler подписан на события (НЕ interval-poll). |
| [03-multi-user-chat.md](./03-multi-user-chat.md) | Сценарий: чат с N пользователями, mention-detection, админ-команды, agent отвечает только когда addressed. |
| [04-sub-agents.md](./04-sub-agents.md) | Orchestrator spawn'ит sub-agent с изолированным контекстом, получает `Flow<SubAgentEvent>`. A2A между независимыми агентами через `:a2a-server`. |
| [05-android-stack.md](./05-android-stack.md) | Что меняется на Android: on-device LLM (NNAPI), Room/sqlite, ONNX-based vector memory, foreground-service для MCP subprocess. |
## Почему SVG + PlantUML source
PlantUML требует Java + (для component/class/deployment диаграмм) Graphviz `dot`. Если `dot` не установлен — рендерер падает с ошибкой "Executable dot does not exist".
Решение: **пре-рендерим в SVG один раз** и вставляем как `<img>`. Диаграмма гарантированно показывается в любом markdown-viewer (GitHub, IntelliJ, VSCode, GitLab) без зависимостей. PlantUML source в code block остаётся для редактирования.
## Как редактировать диаграмму
1. Меняешь PlantUML-source в ` ```plantuml ` блоке `.md` файла.
2. Ре-рендеришь SVG:
```bash
mkdir -p /tmp/plantuml-work && chmod 777 /tmp/plantuml-work
cp docs/diagrams/*.md /tmp/plantuml-work/
docker run --rm -v /tmp/plantuml-work:/work plantuml/plantuml -tsvg /work/*.md
cp /tmp/plantuml-work/*.svg docs/diagrams/
```
3. Проверяешь что SVG обновился:
```bash
ls -la docs/diagrams/*.svg
```
4. Коммитишь оба файла: `.md` (source) и `.svg` (rendered).
Требует Docker (или локального PlantUML+Graphviz). `apt install graphviz` для Arch/Manjaro.
## Контекст
Текущий код движется в эту сторону:
- `:llm-tools` extracted ✅
- `:mcp-bridge` extracted ✅
- `BackgroundScheduler` стал event-driven ✅
- `ConversationLoop` стал отдельным компонентом ✅
Не сделано (см. детали в каждом .md):
- `:agent-core` (выделить `BaseAgent` + builder)
- `:background-events` (выделить events + scheduler)
- `:storage-sqlite-android`, `:memory-vector-android`, `:litert-android`
- `:agentik-android` (само приложение)
- `MentionDetector` interface + adapters для multi-user chat
- `BaseAgent.spawnChild` + `Flow<SubAgentEvent>`
Подробнее:
- `STANDALONE-REVIEW.md` — что плохо в текущем коде
- `MEMORY-DESIGN.md` — детали memory архитектуры
- `STANDALONE.md` — текущий standalone
+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
+51 -2
View File
@@ -2,17 +2,30 @@
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 = "7"
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"
mosaic = "0.18.0"
clikt = "5.0.3"
kotlinx-cli = "0.3.6"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
# JetBrains Compose Compiler plugin — обязательно для @Composable в KMP-проектах
# с Compose Multiplatform 1.8+; без него @Composable-лямбды ломаются (Function0 вместо Function2).
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
[libraries]
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
@@ -25,7 +38,7 @@ 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-jvm", 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 ---
@@ -42,6 +55,7 @@ ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negoti
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
@@ -49,9 +63,44 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
# --- Model Context Protocol (MCP) ---
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
# --- CLI: clikt (ajalt). KMP, native Linux/macOS/Windows включая linuxArm64. ---
# https://ajalt.github.io/clikt/
# Артефакт один и тот же — `com.github.ajalt.clikt:clikt` — Gradle module
# metadata резолвит per-target variant (clikt-jvm / clikt-linuxarm64 / ...).
clikt = { module = "com.github.ajalt.clikt:clikt-core", version.ref = "clikt" }
kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinx-cli" }
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
# https://github.com/JakeWharton/mosaic
mosaic-runtime = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" }
mosaic-runtime-jvm = { module = "com.jakewharton.mosaic:mosaic-runtime-jvm", version.ref = "mosaic" }
mosaic-runtime-macosx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosx64", version.ref = "mosaic" }
mosaic-runtime-macosarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosarm64", version.ref = "mosaic" }
mosaic-runtime-linuxx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxx64", version.ref = "mosaic" }
mosaic-runtime-linuxarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxarm64", version.ref = "mosaic" }
mosaic-runtime-mingwx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-mingwx64", version.ref = "mosaic" }
mosaic-tty-terminal = { module = "com.jakewharton.mosaic:mosaic-tty-terminal", version.ref = "mosaic" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
# --- commons ---
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" }
+37
View File
@@ -0,0 +1,37 @@
// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer,
// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class)
// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах.
//
// Зависимости — все JVM-only контракты: litert.api JVM-only для LiteLlm
// (он и так JVM-only), :memory-api / :storage-core / :skills — commonMain,
// доступные JVM target'у.
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
kotlin {
jvmToolchain(21)
jvm()
sourceSets {
commonMain.dependencies {
api(project(":memory-api"))
api(project(":storage-core"))
api(project(":skills"))
api(libs.litert.api)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
}
jvmMain.dependencies {
// mu.KotlinLogging — JVM-only, для SkillMiner'а
implementation(libs.kotlin.logging)
}
}
}
@@ -0,0 +1,115 @@
package pw.binom.agentik.llm.tools
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteRole
import kotlin.time.Instant
/**
* Один ход диалога в формате, удобном для суммаризации.
*
* Не тянем из audit log напрямую — работаем со своим упрощённым представлением,
* чтобы compaction не зависел от деталей хранения.
*/
data class SummaryTurn(
val userMessage: String,
val assistantMessage: String,
val createdAt: Instant? = null,
)
/**
* Сжимает список прошлых ходов диалога в короткий markdown-саммари.
*
* Суммаризация — ответственность **агента**, потому что зависит от модели
* (context window, summarization prompt, format). Не кладём в `:memory-api`,
* чтобы модуль памяти не знал про LiteLlm.
*
* Имплементация по умолчанию — [LiteLlmContextCompactor] (один-shot LLM-вызов
* по промпту из Hermes `context_compressor.py`).
*/
fun interface ContextCompactor {
suspend fun summarize(turns: List<SummaryTurn>): String
}
/**
* LLM-реализация [ContextCompactor]. Использует отдельный [LiteConversation]
* без tools и без истории — чистый one-shot вызов, который не загрязняет
* KV-cache основного диалога.
*
* Промпт — структура из Hermes `context_compressor.py`:
* - Goal
* - Active State
* - Resolved
* - Blocked / Open Questions
* - Remaining Work
*
* Возвращает короткий markdown-блок (≈ 10-20 строк), который встанет в
* working memory вместо выкинутых ходов.
*/
class LiteLlmContextCompactor(
private val liteLlm: LiteLlm,
private val modelTemperature: Float = 0.2f,
) : ContextCompactor {
override suspend fun summarize(turns: List<SummaryTurn>): String {
if (turns.isEmpty()) return ""
val transcript = turns.joinToString("\n\n") { turn ->
val stamp = turn.createdAt?.toString()?.let { "[$it] " } ?: ""
buildString {
append(stamp).append("USER: ").append(turn.userMessage.trim()).append('\n')
append(stamp).append("ASSISTANT: ").append(turn.assistantMessage.trim())
}
}
val userPrompt = buildString {
appendLine("Transcript of past turns (oldest first):")
appendLine("```")
append(transcript.take(MAX_TRANSCRIPT_CHARS))
if (transcript.length > MAX_TRANSCRIPT_CHARS) appendLine("…(truncated)")
appendLine("```")
appendLine()
appendLine("Produce a compact context summary in this exact structure:")
appendLine("- **Goal**: one-line primary objective of this conversation")
appendLine("- **Active State**: where we are now / what we are currently doing")
appendLine("- **Resolved**: concrete decisions / outputs that are already done")
appendLine("- **Blocked / Open Questions**: things still unresolved")
appendLine("- **Remaining Work**: explicit next steps")
appendLine()
appendLine("Keep total length under ~20 lines. Plain markdown, no preamble.")
}
val cfg = LiteConversationConfig(
systemInstruction = SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
temperature = modelTemperature,
)
val conv: LiteConversation = liteLlm.createConversation(cfg)
try {
val reply = StringBuilder()
conv.sendStreamContents(listOf(LiteContentPart.Text(userPrompt))).collect { delta ->
if (delta.text.isNotEmpty()) reply.append(delta.text)
}
return reply.toString().trim().ifEmpty { "(empty summary)" }
} finally {
runCatching { conv.close() }
}
}
companion object {
private const val MAX_TRANSCRIPT_CHARS: Int = 24_000
private val SYSTEM_PROMPT = """
You are a context compressor for an ongoing AI conversation. Your job is to
produce a compact structured summary of past turns so that the conversation
can continue without losing the user's goal and current state.
Be terse and concrete. Prefer bullet points over prose. Never invent facts
that are not present in the transcript. Do not address the user — this
summary is for internal use by another LLM.
""".trimIndent()
}
}
@@ -0,0 +1,137 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
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.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.storage.Ids
import pw.binom.litert.LiteLlm
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Реализация [MemoryReviewer] поверх on-device LLM (LiteLlm / Google LiteRT-LM).
*
* После каждого хода (или пачки ходов при compaction) зовём LiteLlm с
* специальным промптом, который просит модель вернуть JSON со списком
* новых заметок и удалений. Парсим руками (см. [ReviewDecisionParser]) —
* on-device модели с tool-calling работают ненадёжно, structured output
* стабильнее.
*
* Конструктор принимает `dispatcher` чтобы I/O LiteLlm не блокировал
* основной поток. По умолчанию — `Dispatchers.IO` (вытягивается из контекста).
*
* @param maxExistingFacts сколько последних заметок подмешивать в промпт
* как [memory-context], чтобы модель не дублировала уже сохранённые факты.
*/
class LlmMemoryReviewer(
private val liteLlm: LiteLlm,
private val store: MemoryStore,
private val dispatcher: CoroutineDispatcher,
private val maxExistingFacts: Int = 30,
private val clock: Clock = Clock.System,
) : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = withContext(dispatcher) {
// Снимок существующих заметок — чтобы модель не дублировала
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
val prompt = ReviewPrompts.reviewUserPrompt(turn, existingFacts)
val conv = liteLlm.createConversation(
pw.binom.litert.LiteConversationConfig(
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
temperature = 0.2f,
maxTokens = 512,
)
)
val raw = try {
conv.send(prompt)
} finally {
conv.close()
}
ReviewDecisionParser.parse(raw)
}
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision = withContext(dispatcher) {
if (turns.isEmpty()) return@withContext MemoryReviewDecision()
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
// Склеиваем все ходы в один промпт — модель посмотрит пакетом и сможет
// отсеять дубликаты между ходами.
val prompt = buildString {
if (existingFacts.isNotBlank()) {
appendLine("[memory-context — что уже сохранено]")
appendLine(existingFacts)
appendLine()
}
appendLine("[compacted-turns — будет удалено после compaction'а]")
turns.forEachIndexed { idx, t ->
appendLine()
appendLine("--- turn ${idx + 1} ---")
appendLine("[user] ${t.userMessage}")
appendLine("[assistant] ${t.assistantMessage}")
}
appendLine()
append("Верни JSON:")
}
val conv = liteLlm.createConversation(
pw.binom.litert.LiteConversationConfig(
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
temperature = 0.2f,
maxTokens = 1024,
)
)
val raw = try {
conv.send(prompt)
} finally {
conv.close()
}
ReviewDecisionParser.parse(raw)
}
/**
* Применяет решение к store: сохраняет новые заметки, удаляет помеченные.
* Возвращает сколько заметок записано/удалено — для метрик.
*/
suspend fun apply(decision: MemoryReviewDecision, source: pw.binom.agentik.memory.MemorySource): ApplyResult = withContext(dispatcher) {
var saved = 0
var deleted = 0
for (note in decision.toSave) {
store.upsert(toMemoryNote(note, source))
saved++
}
for (id in decision.toDelete) {
if (store.delete(id)) deleted++
}
ApplyResult(saved = saved, deleted = deleted)
}
private fun toMemoryNote(
note: NewMemoryNote,
source: pw.binom.agentik.memory.MemorySource,
): pw.binom.agentik.memory.MemoryNote {
val now = clock.now()
return pw.binom.agentik.memory.MemoryNote(
id = Ids.new("mem-review"),
category = note.category,
content = note.content,
createdAt = now,
lastUsedAt = now,
useCount = 0,
source = source,
)
}
data class ApplyResult(val saved: Int, val deleted: Int)
}
@@ -0,0 +1,72 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
import pw.binom.agentik.storage.Ids
import pw.binom.agentik.storage.Reflection
import kotlin.time.Clock
/**
* One-shot LLM-размышление о качестве последних ходов диалога.
*
* Использует structured-output JSON prompt (так же как [LlmMemoryReviewer]):
* модель возвращает `{ score: 1-5, summary: "...", weakSpots: ["...", "..."] }`,
* парсер [ReflectionParser] возвращает [Reflection].
*
* Триггер: каждые N ходов (AGENTIK_REFLECTION_INTERVAL, default 10).
* Не блокирует основной диалог — вызывается в фоне на [dispatcher].
*
* @param llm LLM-бэкенд
* @param maxTurns сколько последних ходов передавать модели (default 6)
* @param maxTokens размер ответа LLM (default 512)
* @param dispatcher диспетчер для блокирующего LLM-вызова
* @param clock для генерации id/timestamp
*/
class LlmReflector(
private val llm: LiteLlm,
val maxTurns: Int = 6,
private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val clock: Clock = Clock.System,
) {
/**
* Reflect по последним [turns]. Возвращает [Reflection] или null если
* парсер не смог распарсить (например модель вернула полную ерунду).
*
* Вызов блокирующий: ~1-3 сек на CPU для on-device LiteRT-LM, ~200-500мс
* для OpenAI. Поэтому в проде всегда вызывается из background scope.
*/
suspend fun reflect(turns: List<ConversationTurn>): Reflection? = withContext(dispatcher) {
require(turns.isNotEmpty()) { "need at least one turn to reflect" }
val conversation = llm.createConversation(
config = LiteConversationConfig(
systemInstruction = ReflectionPrompts.SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
)
)
try {
val userPrompt = ReflectionPrompts.buildUserPrompt(
turns = turns.takeLast(maxTurns),
maxTurns = maxTurns,
)
val raw = conversation.send(userPrompt)
val parsed = ReflectionParser.parse(raw)
?: return@withContext null
Reflection(
id = Ids.reflection(),
conversationId = null, // будет проставлен caller'ом ChatConversation
createdAt = clock.now(),
turnsAnalyzed = turns.size,
score = parsed.score.coerceIn(1, 5),
summary = parsed.summary,
weakSpots = parsed.weakSpots,
)
} finally {
conversation.close()
}
}
}
@@ -0,0 +1,117 @@
package pw.binom.agentik.llm.tools
/**
* Минимальный парсер JSON-ответа от [LlmReflector].
*
* Ожидаемая форма:
* ```
* {"score": 4, "summary": "...", "weakSpots": ["...", "..."]}
* ```
*
* Допуски:
* - Модель может обернуть ответ в ```json ... ``` fences — обрезаем.
* - Может быть лидирующий/завершающий текст до/после JSON — находим первую
* `{` и парсим баланс скобок до парной `}`.
* - `score` может быть числом или строкой ("4") — оба варианта ок.
* - `weakSpots` может быть пустым массивом.
* - Любые невалидные символы → null (defensive: лучше пропустить рефлексию,
* чем уронить agent loop).
*/
object ReflectionParser {
data class Parsed(val score: Int, val summary: String, val weakSpots: List<String>)
fun parse(raw: String): Parsed? {
val json = extractJsonObject(raw) ?: return null
val score = extractIntField(json, "score") ?: return null
val summary = extractStringField(json, "summary") ?: ""
val weakSpots = extractStringArrayField(json, "weakSpots") ?: emptyList()
return Parsed(score = score, summary = summary, weakSpots = weakSpots)
}
/**
* Извлекает JSON-объект из произвольного текста: обрезает ``` fences,
* пропускает префикс/суффикс, ищет первую `{` и парную `}` по балансу скобок.
*/
internal fun extractJsonObject(raw: String): String? {
var s = raw.trim()
// Strip ```json / ``` fences
if (s.startsWith("```")) {
val firstNewline = s.indexOf('\n')
if (firstNewline > 0) s = s.substring(firstNewline + 1)
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
}
val open = s.indexOf('{')
if (open < 0) return null
var depth = 0
var i = open
var inString = false
var escape = false
while (i < s.length) {
val c = s[i]
if (escape) { escape = false; i++; continue }
if (c == '\\' && inString) { escape = true; i++; continue }
if (c == '"') { inString = !inString; i++; continue }
if (!inString) {
when (c) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) return s.substring(open, i + 1)
}
}
}
i++
}
return null
}
/** Достаёт числовое поле из JSON-объекта: "score": 4 или "score": "4". */
internal fun extractIntField(json: String, name: String): Int? {
val re = Regex(""""$name"\s*:\s*(?:(\d+)|"(\d+)")""")
val match = re.find(json) ?: return null
val n = match.groupValues[1].ifEmpty { match.groupValues[2] }
return n.toIntOrNull()
}
/** Достаёт строковое поле: "summary": "..." с `\"` и `\\` escape. */
internal fun extractStringField(json: String, name: String): String? {
val re = Regex(""""$name"\s*:\s*"((?:\\.|[^"\\])*)"""")
val match = re.find(json) ?: return null
return unescape(match.groupValues[1])
}
/** Достаёт массив строк: "weakSpots": ["a", "b"]. Возвращает пустой список если поле отсутствует. */
internal fun extractStringArrayField(json: String, name: String): List<String>? {
val re = Regex(""""$name"\s*:\s*\[([^\]]*)]""")
val match = re.find(json) ?: return null
val inner = match.groupValues[1]
if (inner.isBlank()) return emptyList()
val out = mutableListOf<String>()
val itemRe = Regex("""\"((?:\\.|[^\"\\])*)\"""")
for (m in itemRe.findAll(inner)) {
out.add(unescape(m.groupValues[1]))
}
return out
}
private fun unescape(s: String): String = buildString {
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '\\' && i + 1 < s.length) {
when (s[i + 1]) {
'"' -> append('"')
'\\' -> append('\\')
'n' -> append('\n')
't' -> append('\t')
else -> append(s[i + 1])
}
i += 2
} else {
append(c)
i++
}
}
}
}
@@ -0,0 +1,61 @@
package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ConversationTurn
/**
* Промпты для [LlmReflector] — one-shot self-reflection.
*
* Стиль: structured-output (модель отвечает JSON, не зовёт тулзы).
* Это та же техника, что в `LlmMemoryReviewer`: on-device LiteRT-LM
* плохо работает с tool-calling, но стабильно отвечает на JSON-prompt
* при явном `respond with JSON` указании.
*/
object ReflectionPrompts {
/**
* System-prompt для размышления.
* Русский — потому что весь остальной agentik тоже ru-flavored
* (review-prompt, MemorySystemGuidance и т.п.).
*/
const val SYSTEM_PROMPT = """Ты — критический аналитик собственной работы ассистента.
Тебе дадут последние ходы диалога: пары user/assistant сообщений.
Оцени, насколько хорошо ассистент справился с задачами пользователя.
Шкала score (одно целое число):
1 — ассистент путался, галлюцинировал, не отвечал на вопрос, игнорировал контекст.
2 — были заметные проблемы (неточные факты, странные ответы).
3 — нормальная работа, ничего особенного.
4 — хорошая работа, помог пользователю, был полезным.
5 — отличная работа: точный, полезный, уместный.
weakSpots — это массив КОРОТКИХ строк (1-3 слова каждая), конкретные слабые
места, которые заметил. Примеры:
- "медленно отвечаю на вопросы про X"
- "путаю A и B"
- "слишком длинные ответы на простые вопросы"
- "не помню контекст разговора"
summary — свободный markdown-комментарий (1-3 предложения): что именно
было хорошо, что плохо, что улучшить.
ВАЖНО: ответь СТРОГО JSON объектом:
{"score": <1-5>, "summary": "<markdown>", "weakSpots": ["...", "..."]}
Никаких пояснений до или после JSON. Только валидный JSON."""
/**
* User-prompt: последние ходы диалога. Каждый ход — пара
* `[user] text` / `[assistant] text`. Старые ходы обрезаются до [maxTurns].
*/
fun buildUserPrompt(turns: List<ConversationTurn>, maxTurns: Int): String = buildString {
appendLine("Последние ${turns.size} из $maxTurns ходов диалога:")
appendLine()
for ((idx, turn) in turns.withIndex()) {
appendLine("--- Ход ${idx + 1} ---")
appendLine("[user]: ${turn.userMessage}")
appendLine("[assistant]: ${turn.assistantMessage}")
appendLine()
}
append("Оцени по шкале и верни JSON.")
}
}
@@ -0,0 +1,195 @@
package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.NewMemoryNote
/**
* Парсер ответа LLM-review-loop'а.
*
* LiteLlm (on-device) не имеет надёжного tool-calling flow, поэтому
* модель возвращает JSON в plain text. Парсим регуляркой + минимальным
* валидатором — если что-то не так, лучше no-op, чем краш.
*/
object ReviewDecisionParser {
/**
* Парсит ответ модели в [MemoryReviewDecision]. Возвращает пустой decision
* если ответ пустой, не JSON, или JSON битый — лучше ничего не сохранить,
* чем записать мусор.
*/
fun parse(rawOutput: String): MemoryReviewDecision {
val trimmed = rawOutput.trim()
if (trimmed.isEmpty()) return MemoryReviewDecision()
// Ищем JSON-блок, даже если модель обернула его в ``` или добавила пояснения
val json = extractJson(trimmed) ?: return MemoryReviewDecision()
return parseJson(json)
}
private fun extractJson(text: String): String? {
// Первый '{' до последней '}'
val start = text.indexOf('{')
val end = text.lastIndexOf('}')
if (start < 0 || end < 0 || end <= start) return null
return text.substring(start, end + 1)
}
/**
* Минимальный JSON-парсер. Не хочу тянуть kotlinx-serialization в этот
* слой — JSON простой (плоский массив объектов), пишем руками.
*/
private fun parseJson(json: String): MemoryReviewDecision {
return try {
val save = parseArray(json, "save") { obj ->
val category = parseString(obj, "category")?.let { runCatching { MemoryCategory.valueOf(it.uppercase()) }.getOrNull() }
?: return@parseArray null
val content = parseString(obj, "content")?.takeIf { it.isNotBlank() }
?: return@parseArray null
NewMemoryNote(category, content)
}
val delete = parseStringArray(json, "delete")
MemoryReviewDecision(toSave = save, toDelete = delete)
} catch (e: Exception) {
MemoryReviewDecision()
}
}
/**
* Парсит массив объектов из JSON-строки по ключу. callback получает
* содержимое одного элемента (без обрамляющих []{} и без имени ключа)
* и возвращает элемент результата либо null (пропустить).
*/
private fun <T> parseArray(json: String, key: String, map: (String) -> T?): List<T> {
// Ищем "key": [ ... ]
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return emptyList()
val arrayStart = json.indexOf('[', keyIdx)
val arrayEnd = json.indexOf(']', arrayStart)
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
return splitTopLevelObjects(arrayContent).mapNotNull { map(it) }
}
/**
* Разбивает содержимое JSON-массива на отдельные объекты верхнего уровня
* с учётом вложенности и экранирования кавычек.
*/
private fun splitTopLevelObjects(content: String): List<String> {
val result = mutableListOf<String>()
var depth = 0
var start = -1
var inString = false
var escaped = false
content.forEachIndexed { i, ch ->
if (escaped) { escaped = false; return@forEachIndexed }
when {
ch == '\\' && inString -> escaped = true
ch == '"' -> inString = !inString
!inString && ch == '{' -> {
if (depth == 0) start = i
depth++
}
!inString && ch == '}' -> {
depth--
if (depth == 0 && start >= 0) {
result.add(content.substring(start, i + 1))
start = -1
}
}
}
}
return result
}
/**
* Парсит массив строк из JSON по ключу. Используется для `delete`
* (там элементы — голые строки, не объекты).
*/
private fun parseStringArray(json: String, key: String): List<String> {
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return emptyList()
val arrayStart = json.indexOf('[', keyIdx)
val arrayEnd = json.indexOf(']', arrayStart)
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
val result = mutableListOf<String>()
var i = 0
while (i < arrayContent.length) {
// Skip whitespace and commas
while (i < arrayContent.length && (arrayContent[i].isWhitespace() || arrayContent[i] == ',')) i++
if (i >= arrayContent.length || arrayContent[i] != '"') break
i++ // skip opening quote
val sb = StringBuilder()
var escaped = false
while (i < arrayContent.length) {
val c = arrayContent[i]
if (escaped) {
when (c) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(c)
}
escaped = false
i++
continue
}
when (c) {
'\\' -> { escaped = true; i++ }
'"' -> {
result.add(sb.toString())
i++
break
}
else -> { sb.append(c); i++ }
}
}
}
return result
}
/**
* Парсит строковое значение по ключу в JSON-объекте. Возвращает
* содержимое без обрамляющих кавычек и с раскрытыми базовыми escape.
*/
private fun parseString(obj: String, key: String): String? {
val keyIdx = obj.indexOf("\"$key\"")
if (keyIdx < 0) return null
val colon = obj.indexOf(':', keyIdx)
if (colon < 0) return null
val firstQuote = obj.indexOf('"', colon)
if (firstQuote < 0) return null
// Ищем закрывающую кавычку с учётом escape
var i = firstQuote + 1
val sb = StringBuilder()
while (i < obj.length) {
val c = obj[i]
when {
c == '\\' && i + 1 < obj.length -> {
when (val next = obj[i + 1]) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(next)
}
i += 2
}
c == '"' -> return sb.toString()
else -> {
sb.append(c)
i++
}
}
}
return null
}
}
@@ -0,0 +1,59 @@
package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ReviewedTurn
/**
* Промпты для review-loop'а через LiteLlm.
*
* Hermes делает это с OpenAI/Anthropic tool-calling flow. У нас on-device
* движок (Google LiteRT-LM) — там tool-calling ненадёжен, поэтому используем
* structured-output: модель должна вернуть JSON, который мы парсим регуляркой.
*/
object ReviewPrompts {
/**
* Системная инструкция для review-loop'а. На русском — модель у нас
* русскоязычная (gemma-2-9b / qwen / и т.п.), английский промпт часто
* даёт хуже результат на ru-данных.
*/
const val REVIEW_SYSTEM_PROMPT = """Ты — агент ревью памяти. Твоя задача — проанализировать пару (сообщение пользователя, ответ ассистента) и решить, что из неё стоит сохранить в долговременную память.
Категории памяти:
- USER — факты о пользователе (имя, профессия, предпочтения, контекст его жизни)
- WORLD — факты о внешнем мире (проекты, технологии, организации, конкретные API/документация)
- PREFERENCE — предпочтения по формату/поведению ассистента (стиль кода, длины ответов, инструменты)
Правила:
1. Сохраняй ТОЛЬКО durable facts — то, что останется актуальным через недели. Не сохраняй "пользователь поздоровался" или "ассистент использовал grep".
2. Не дублируй уже сохранённое — если факт уже есть в [memory-context], пропусти.
3. Каждый факт — одна короткая фраза. Не абзацы, не "the user mentioned...".
4. Не выдумывай. Если ничего достойного — верни пустой массив.
Формат ответа — строго JSON без обрамления ```json и без пояснений:
{"save":[{"category":"USER|WORLD|PREFERENCE","content":"..."}],"delete":[]}
Если нечего сохранять:
{"save":[],"delete":[]}"""
/**
* Форматирует user-prompt для review-loop'а. Подаёт текущий ход +
* текущее состояние долговременной памяти (чтобы избежать дубликатов).
*/
fun reviewUserPrompt(
turn: ReviewedTurn,
existingFacts: String,
): String = buildString {
if (existingFacts.isNotBlank()) {
appendLine("[memory-context — что уже сохранено]")
appendLine(existingFacts)
appendLine()
}
appendLine("[user]")
appendLine(turn.userMessage)
appendLine()
appendLine("[assistant]")
appendLine(turn.assistantMessage)
appendLine()
append("Верни JSON:")
}
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
private val log = mu.KotlinLogging.logger {}
/**
* Фоновый минер скилов (skill mining): сетка безопасности для skill self-improvement.
*
* Модель в ходе разговора может "протупить" и не вызвать `skill_save`, хотя приём
* был действительно переиспользуемым. [SkillMiner] периодически (каждые N ходов,
* см. `AGENTIK_SKILL_MINING_INTERVAL`) берёт последние ходы диалога, показывает их
* LLM вместе с текущим каталогом скилов и просит structured-output JSON:
* {"skills": [{"name","description","body"}]}. Найденные скилы upsert-ятся в
* [pw.binom.agentik.skills.SkillStore] — агент становится умнее между сессиями
* даже без прямого tool-call в ходе разговора.
*
* Архитектурно — точный аналог [LlmReflector]: короткоживущий LiteConversation
* (один прогон = один LLM-вызов), blocking-инференс на [dispatcher], defensive
* парсинг [SkillMiningParser] (кривой ответ → пустой список, не ломает agent loop).
*
* @param llm LLM-бэкенд
* @param maxTurns сколько последних ходов передавать модели (default 30)
* @param maxTokens потолок ответа модели (default 1536 — body скила бывает длинным)
* @param dispatcher диспетчер для блокирующего LLM-вызова
*/
class SkillMiner(
private val llm: LiteLlm,
val maxTurns: Int = 30,
private val maxTokens: Int = 1536,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* Mine по последним [turns] с учётом текущего каталога [existing].
*
* @return найденные/обновлённые скилы; пустой список — нечего сохранять
* или модель ответила мусором (defensive: прогон просто пропускается).
*
* Вызов блокирующий: ~1-3 с на CPU для on-device LiteRT-LM. Всегда вызывать
* из background scope (хук [pw.binom.agentik.standalone.agent.ChatConversation]
* или debug-эндпоинт).
*/
suspend fun mine(turns: List<ConversationTurn>, existing: List<SkillFile>): List<SkillFile> =
withContext(dispatcher) {
val batch = turns.takeLast(maxTurns)
if (batch.isEmpty()) return@withContext emptyList()
val conversation = llm.createConversation(
config = LiteConversationConfig(
systemInstruction = SkillMiningPrompts.SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
temperature = 0.2f,
maxTokens = maxTokens,
)
)
try {
val userPrompt = SkillMiningPrompts.buildUserPrompt(batch, existing)
val raw = conversation.send(userPrompt)
val mined = SkillMiningParser.parse(raw)
if (mined.isNotEmpty()) {
log.info { "skill-mine: found ${mined.size} skill(s) from ${batch.size} turns: ${mined.map { it.name }}" }
}
mined
} catch (e: Throwable) {
log.warn(e) { "skill-mine: LLM call failed, skipping pass" }
emptyList()
} finally {
conversation.close()
}
}
}
@@ -0,0 +1,207 @@
package pw.binom.agentik.llm.tools
import pw.binom.agentik.skills.SkillFile
/**
* Минимальный парсер JSON-ответа [SkillMiner] (в том же defensive-стиле, что
* [ReflectionParser] и [ReviewDecisionParser]: без kotlinx-serialization, чтобы
* кривой ответ локальной модели не ронял agent loop).
*
* Ожидаемая форма:
* ```
* {"skills": [{"name": "x", "description": "...", "body": "..."}]}
* ```
* Допуски:
* - ответ может быть обёрнут в ```json ... ``` fences;
* - может быть текст до/после JSON — ищем первый `{` (или `[`) и балансируем;
* - допустим и голый массив `[{...}, {...}]` без ключа `"skills"`;
* - `body` может содержать `\n`, `\"`, `\\` — деэскейпим;
* - `description`/`body` могут отсутствовать (тогда пустые);
* - любой мусор (невалидные кавычки, незакрытые скобки) → пустой список,
* mining-прогон просто не сохранит ничего.
*/
object SkillMiningParser {
fun parse(raw: String): List<SkillFile> {
val blob = extractJsonBlob(raw) ?: return emptyList()
val array = extractSkillsArray(blob) ?: return emptyList()
return splitTopLevelObjects(array).mapNotNull { obj ->
val name = extractStringField(obj, "name")?.trim().orEmpty()
if (name.isEmpty()) return@mapNotNull null
val description = extractStringField(obj, "description")?.trim().orEmpty()
val body = extractStringField(obj, "body")?.trim().orEmpty()
SkillFile(name = name, description = description, body = body)
}
}
/**
* Обрезает ``` fences, находит первый `{` или `[` и возвращает подстроку
* до парной закрывающей (со знанием состояний string/escape).
*/
internal fun extractJsonBlob(raw: String): String? {
var s = raw.trim()
if (s.startsWith("```")) {
val nl = s.indexOf('\n')
if (nl > 0) s = s.substring(nl + 1)
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
}
val open = minOf(
s.indexOf('{').takeIf { it >= 0 } ?: Int.MAX_VALUE,
s.indexOf('[').takeIf { it >= 0 } ?: Int.MAX_VALUE,
)
if (open == Int.MAX_VALUE) return null
val opener = s[open]
val closer = if (opener == '{') '}' else ']'
var depth = 0
var inString = false
var escape = false
for (i in open until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
opener, '{', '[' -> depth++
'}', ']' -> {
depth--
if (depth == 0 && ((c == closer) || (opener == '{' && c == '}') || (opener == '[' && c == ']'))) {
return s.substring(open, i + 1)
}
}
}
}
return null
}
/**
* Находит массив скилов: если в blob есть ключ `"skills"` — массив после него,
* иначе сам blob (если начинается с `[`).
*/
internal fun extractSkillsArray(blob: String): String? {
val keyIdx = blob.indexOf("\"skills\"")
if (keyIdx >= 0) {
val colon = blob.indexOf(':', keyIdx + "skills".length + 2)
if (colon < 0) return null
val open = blob.indexOf('[', colon + 1)
if (open < 0) return null
return balanceArray(blob, open)
}
if (blob.startsWith('[')) return blob.drop(1).dropLast(1)
return null
}
/** Балансирует `[...]` от [start] (включительно). Возвращает содержимое без скобок. */
private fun balanceArray(s: String, start: Int): String? {
var depth = 0
var inString = false
var escape = false
for (i in start until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
'[' -> depth++
']' -> {
depth--
if (depth == 0) return s.substring(start + 1, i)
}
}
}
return null
}
/** Разбивает содержимое массива на топ-уровневые `{...}` объекты (string-aware). */
internal fun splitTopLevelObjects(arrayContent: String): List<String> {
val out = mutableListOf<String>()
var i = 0
while (i < arrayContent.length) {
if (arrayContent[i] == '{') {
var depth = 0
var inString = false
var escape = false
var j = i
while (j < arrayContent.length) {
val c = arrayContent[j]
if (escape) { escape = false; j++; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
} else {
when (c) {
'"' -> inString = true
'{' -> depth++
'}' -> {
depth--
if (depth == 0) {
out.add(arrayContent.substring(i, j + 1))
i = j + 1
break
}
}
}
}
j++
}
if (j >= arrayContent.length) break
} else {
i++
}
}
return out
}
/**
* Достаёт первое строковое поле `"<name>": "..."` из JSON-объекта с
* поддержкой escapes (`\"`, `\\`, `\n`, `\t`). `null` если поля нет.
*/
internal fun extractStringField(obj: String, name: String): String? {
val keyRe = Regex(""""$name"\s*:""")
val keyMatch = keyRe.find(obj) ?: return null
val colonIdx = keyMatch.range.last
// Пропускаем пробельные символы после :
var i = colonIdx + 1
while (i < obj.length && (obj[i] == ' ' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == '\t')) i++
if (i >= obj.length || obj[i] != '"') {
// Значение не строка (null/число) — не поддерживаем.
return null
}
i++ // открывающая кавычка
val sb = StringBuilder()
while (i < obj.length) {
val c = obj[i]
if (c == '\\' && i + 1 < obj.length) {
when (val esc = obj[i + 1]) {
'"' -> sb.append('"')
'\\' -> sb.append('\\')
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
else -> sb.append(esc)
}
i += 2
} else if (c == '"') {
return sb.toString()
} else {
sb.append(c)
i++
}
}
// Незакрытая строка — мусор от модели, считаем null.
return null
}
}
@@ -0,0 +1,80 @@
package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
/**
* Промпты для [SkillMiner]: structured-output JSON.
*
* Тот же подход, что [ReflectionPrompts] и [LlmMemoryReviewer]: локальной модели
* (LiteRT-LM) не доверяем tool-calling, поэтому просим строго JSON и парсим руками
* ([SkillMiningParser]).
*/
object SkillMiningPrompts {
/**
* System prompt. Задаёт роль "минёра скилов": модель смотрит на последние
* ходы разговора и решает, есть ли в них переиспользуемый приём, который
* стоит закрепить в скиле, чтобы агент становился умнее между сессиями.
*
* Ключевое отличие от `skill_save` (в-ходе): mining — это сетка безопасности,
* если модель в ходе разговора забыла сохранить скил. Поэтому промпт жёсткий
* по критериям: только реально переиспользуемое, без дублей каталога.
*/
val SYSTEM_PROMPT: String = """
Ты — фоновый минер навыков (skill miner) для ИИ-агента.
Тебе показывают последние ходы разговора агента с пользователем и каталог
его текущих навыков (скилов). Твоя задача — найти в этих ходах переиспользуемый
приём, процедуру или паттерн, который агент применит в БУДУЩИХ, других
разговорах. Такой приём закрепляется как скил, и агент с ним становится
умнее.
Критерии, ЧТО сохращать:
- конкретная воспроизводимая процедура (шаги, команды, форматы, приёмы);
- решение проблемы, которое модель выработала и в другом разговоре повторит;
- проверка/валидация, о которой модель "забыла" и её стоит закрепить.
ЧТО НЕ сохраняй:
- разовые факты конкретного разговора (это не приём, а данные);
- тривиальность ("ответь кратко"), которую и так видно из контекста;
- дубли уже существующих скилов в каталоге — если приём уже есть,
предложи ОБНОВЛЕНИЕ (то же имя, улучшённый body), а не новый скил.
Вывод — строго JSON, без пояснений до и после:
{"skills": [{"name": "...", "description": "...", "body": "..."}]}
Если сохранять нечего — {"skills": []}
Правила по полям:
- name: kebab-case или иерархия через двоеточие (например, "backend:spring:db-base");
короткое, 2-5 слов. Если обновляешь существующий скил — ТОЧНО его имя.
- description: 1-2 предложения, когда/зачем применять скил.
- body: markdown-инструкция: краткое описание + нумерованные шаги + примеры.
Только то, что агент должен помнить; без воды.
""".trimIndent()
/**
* User prompt: последние [turns] разговора + каталог существующих скилов.
* Ходы нумеруются, чтобы модель понимала хронологию.
*/
fun buildUserPrompt(turns: List<ConversationTurn>, existing: List<SkillFile>): String = buildString {
appendLine("### Последние ходы разговора (по хронологии)")
turns.forEachIndexed { i, t ->
appendLine()
appendLine("--- Ход ${i + 1} ---")
appendLine("[user] ${t.userMessage.take(1500)}")
appendLine("[assistant] ${t.assistantMessage.take(2000)}")
}
appendLine()
appendLine("### Текущий каталог скилов (не дубли, обновляй при необходимости)")
if (existing.isEmpty()) {
appendLine("(пока нет)")
} else {
for (s in existing) {
appendLine("- ${s.name}: ${s.description.take(160)}")
}
}
appendLine()
appendLine("Если есть что сохранить (или обновить существующий) — выведи JSON. Иначе {\"skills\": []}.")
}
}
+39
View File
@@ -0,0 +1,39 @@
// Generic MCP (Model Context Protocol) bridge — переиспользуемый модуль,
// который превращает любой MCP-сервер (stdio subprocess или HTTP endpoint)
// в набор [LiteTool]-адаптеров.
//
// Вынесен из :standalone — MCP не специфичен для standalone'а, это generic
// мост между MCP-SDK и litert-kmp. Может переиспользоваться в :agentik-cli
// или :agentik-tui когда те снова включатся.
//
// Зависимости:
// - :agent-toolsets для NamedTool (обёртка для LiteTool + имя-как-видит-модель)
// - litert.api для LiteTool контракта
// - MCP SDK (JVM-only)
// - Ktor client (для StreamableHttpClientTransport)
// - kotlinx-serialization для парсинга конфига
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
}
dependencies {
implementation(project(":agent-toolsets"))
api(libs.litert.api)
implementation(libs.mcp.sdk.client)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlin.logging)
}
@@ -1,4 +1,5 @@
package pw.binom.agentik.standalone.mcp
package pw.binom.agentik.mcp.bridge
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -57,13 +58,14 @@ data class McpConfig(
val isEmpty: Boolean get() = servers.isEmpty()
companion object {
private val log = mu.KotlinLogging.logger {}
private val json = Json { ignoreUnknownKeys = true }
fun fromEnv(env: (String) -> String? = System::getenv): McpConfig {
val path = env("AGENTIK_MCP_CONFIG")?.takeIf { it.isNotBlank() } ?: return empty()
val file = File(path)
if (!file.exists()) {
System.err.println("[agentik] AGENTIK_MCP_CONFIG points to missing file: $path")
log.warn { "AGENTIK_MCP_CONFIG points to missing file: $path" }
return empty()
}
return fromJson(file.readText())
@@ -96,7 +98,7 @@ data class McpConfig(
?: emptyMap()
return McpServerSpec.Stdio(name = name, command = command, args = args, env = env)
}
System.err.println("[agentik] MCP server '$name' has neither 'url' nor 'command' — skipped")
log.warn { "MCP server '$name' has neither 'url' nor 'command' — skipped" }
return null
}
}
@@ -1,4 +1,6 @@
package pw.binom.agentik.standalone.mcp
package pw.binom.agentik.mcp.bridge
import mu.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
@@ -28,9 +30,9 @@ import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap
import pw.binom.agentik.toolsets.NamedTool
/**
* Реестр подключённых MCP-серверов.
@@ -42,6 +44,7 @@ import java.util.concurrent.ConcurrentHashMap
*
* [close] убивает stdio-процессы и закрывает HTTP-клиент.
*/
private val log = KotlinLogging.logger {}
class McpRegistry(
private val servers: List<McpServerSpec>,
private val httpClient: HttpClient = defaultHttpClient(),
@@ -72,10 +75,10 @@ class McpRegistry(
val server = connectOne(spec)
connected[spec.name] = server
}.onFailure { e ->
System.err.println("[agentik] MCP server '${spec.name}' failed to connect: ${e.message}")
log.warn { "MCP server '${spec.name}' failed to connect: ${e.message}" }
}
}
System.err.println("[agentik] MCP registry: ${connected.size}/${servers.size} servers connected, ${allTools.size} tools total")
log.warn { "MCP registry: ${connected.size}/${servers.size} servers connected, ${allTools.size} tools total" }
}
}
@@ -84,7 +87,7 @@ class McpRegistry(
val transport: Transport = when (spec) {
is McpServerSpec.Stdio -> {
val cmd = (listOf(spec.command) + spec.args).joinToString(" ")
System.err.println("[agentik] MCP stdio '$spec.name': $cmd")
log.warn { "MCP stdio '$spec.name': $cmd" }
val pb = ProcessBuilder(buildList { add(spec.command); addAll(spec.args) })
.redirectErrorStream(false)
spec.env.forEach { (k, v) -> pb.environment()[k] = v }
@@ -97,7 +100,7 @@ class McpRegistry(
)
}
is McpServerSpec.Http -> {
System.err.println("[agentik] MCP http '$spec.name': ${spec.url}")
log.warn { "MCP http '$spec.name': ${spec.url}" }
StreamableHttpClientTransport(
client = httpClient.config {
if (spec.headers.isNotEmpty()) {
@@ -155,18 +158,16 @@ class McpRegistry(
* Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий
* между MCP-серверами (например, оба могут иметь tool `search`).
*
* [describe] сериализует tool в JSON-дескриптор в формате, который litert-openai
* и litert-google принимают как function-calling definition:
* [describe] сериализует tool в JSON-дескриптор — flat OpenAPI-спецификация
* (формат, который напрямую принимает LiteRT-LM; litert-openai сам оборачивает
* её в OpenAI-формат):
*
* ```json
* {
* "type": "function",
* "function": {
* "name": "<server>__<tool>",
* "description": "...",
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
* }
* }
* ```
*
* [invoke] вызывает [Client.callTool] по оригинальному (непрефиксованному) имени тула
@@ -184,12 +185,11 @@ internal class McpLiteToolAdapter(
override fun describe(): String =
buildJsonObject {
put("type", "function")
put("function", buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName)
put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema())
})
}.toString()
override fun invoke(arguments: String): String {
@@ -230,7 +230,7 @@ internal class McpLiteToolAdapter(
val parsed = json.parseToJsonElement(raw)
if (parsed !is JsonObject) emptyMap() else parsed.toAnyMap()
} catch (e: Throwable) {
System.err.println("[agentik] MCP tool '$toolName' got invalid args JSON: ${e.message}")
log.warn { "MCP tool '$toolName' got invalid args JSON: ${e.message}" }
emptyMap()
}
}
+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>
}

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