Добавлена собственная авторизация по токену. Это ОТДЕЛЬНАЯ подсистема:
библиотека A2A (pw.binom.a2a) имеет свой независимый token, общих типов
и общей логики не вводится.
Поведение по умолчанию не меняется: token = null -> авторизация выключена,
сервер открыт (обратная совместимость), CLI/TUI не затронуты.
Сервер (:server):
- новый route-scoped плагин BearerTokenPlugin (BearerTokenConfig);
- agentikAgent(agent, path, token) ставит плагин на всё поддерево /agentik,
когда token != null; иначе плагин не устанавливается;
- при несовпадении заголовка Authorization: Bearer <token> -> 401 Unauthorized;
- /health всегда открыт (liveness для балансировщика).
Клиент (:client):
- defaultAgentikHttpClient(token) навешивает Authorization: Bearer <token>
через DefaultRequest на весь HttpClient -> накрывает все 10 вызовов и оба SSE;
- AgentikAgent(id, baseUrl, token, httpClient) — token необязательный,
9 существующих мест создания агента не тронуты.
Standalone:
- AgentSection.authToken (env AGENTIK_TOKEN) -> /agentik;
- AgentSection.a2aToken (env AGENTIK_A2A_TOKEN) -> /a2a;
- два независимых значения, связи между ними нет.
Тесты: BearerTokenTest (5), BearerHeaderTest (3) — 401 без токена и с чужим,
200 с верным, /health открыт, null -> открыто. Мутационная проверка пройдена.
- 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); версия = имя тега релиза
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.
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
- 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 работают.
- Добавил нативные таргеты с реальной реализацией (не 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,
платформенные детали.
- :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).
Пользователь признал 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: убрана ссылка в комментарии
Дефолтный 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 (контр-тест, доказывает что баг был).
Закрывает разрыв между :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 не задет.
Удалён job build-fatjars + upload-artifact + Attach-to-release-API.
Сборка и прикрепление fatjar-ов делается локально (./gradlew
:<module>:shadowJar) и через Gitea UI/API руками. CICD занимается
только тем, что умеет: публикует библиотеки в Nexus caffeine.
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.
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'.
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).
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]
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]
- 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 как бинарные ассеты.
Вместо 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.
Добавляет 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.
Три фикса в 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).
После 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.
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.
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.
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).
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).
В предыдущей версии 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' к моменту создания публикации.
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).
Раньше 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 его не читает.
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).
- 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 загружается
Удаляю DemoToolset.kt, поле AgentikConfig.demoToolset и парсер
AGENTIK_DEMO_TOOLSET — после ночной e2e-проверки toolsets
они больше не нужны в проде. Механика покрыта ChatAgentToolsetsTest
через stub-тулы inline. Если потребуется live e2e — проще
прокинуть свой ToolsetContribution-список из main/test.
Финальный 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. Без регрессий.
Добавлен 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)
Новый 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)