24212a1003
Новый модуль :integrations-telegram, опциональный «плагин» для standalone:
* TelegramBridgeComponent — Component-имплементация, устанавливается
в agent через agent.install(...) только при заданном
AGENTIK_TELEGRAM_TOKEN. Нет токена — нет polling'а, classpath не
содержит pw.binom.telegram.* (factory возвращает AutoCloseable,
а Component-объект создаётся внутри factory и утекает в
standalone только через agent-api).
* Long-polling getUpdates?timeout=N — не webhook, не нужен публичный URL.
* chatId ↔ ConversationId маппинг в общей SQLite-БД standalone'а
(таблица tg_chat_map); persistent=true переиспользует conv_id,
persistent=false — каждый Telegram-message = свежий temp-диалог.
* Group-чаты и /new стартуют временный диалог, не пишут в мапу.
* Tool-call показывает «⚙️ обрабатываю…» через sendChatAction(typing)
по onlineOutbox.onlineEvents.
* Outbound streaming через editMessageText с guard'ом «message is not
modified»; End с пустым телом → «(empty response)».
* Ошибки conv.send — одной строкой в чат, polling не падает.
* uninstall отменяет scope, polling и stream-джобы корректно
останавливаются.
Конфиг — через env (AppConfig.fromEnv):
* AGENTIK_TELEGRAM_TOKEN (default пусто → модуль не активен)
* AGENTIK_TELEGRAM_POLLING_TIMEOUT (default 30s, ок 25–35)
* AGENTIK_TELEGRAM_PERSISTENT (default true)
Зависимость: наша in-house либа caffeine-mgn/telegramClient
(pw.binom.telegram:telegramClient:1.0.0-SNAPSHOT из mavenLocal).
standalone/Main.kt:
* integrationsScope (SupervisorJob + Dispatchers.Default) создаётся
до server'а; tgClient закрывается shutdown-hook'ом ДО agent.close()
и integrationsScope.cancel() — порядок важен, иначе polling
long-poll не прерывается и JVM висит на выходе.
Тесты (8/8 ✅):
* первый входящий текст → persistent conv + conv.send
* повтор из того же chat → тот же conv
* non-persistent → каждый message = новый temp-conv
* typing-индикатор на каждом сообщении
* outbound streaming → 3 editMessage подряд
* End с пустым телом → draft заменяется на «(empty response)»
* ошибка conv.send → одна строка в чат, polling не падает
* uninstall → polling остановлен, новые push'и не обрабатываются
standalone/README.md — добавлен раздел «Telegram-интеграция» с env-таблицей,
описанием поведения и инструкцией по включению.
241 lines
12 KiB
Kotlin
241 lines
12 KiB
Kotlin
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||
|
||
import org.gradle.api.artifacts.ConfigurationContainer
|
||
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)
|
||
}
|
||
|
||
// CI-флаг: при -PskipVectorMemory=true :memory-vector не подключается как
|
||
// зависимость — нужно для CI runner'а (text-embedding-kmp:api/siglip-jvm
|
||
// опубликованы в caffeine начиная с v4, 2026-09-21). Подробнее см.
|
||
// memory-vector/build.gradle.kts.
|
||
val skipVectorMemory: Boolean =
|
||
(project.findProperty("skipVectorMemory") == "true") ||
|
||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||
|
||
// :standalone — единственный entry-point проекта, JVM-only по факту (один
|
||
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
|
||
// commonMain + commonTest — даже зависимости, которые формально JVM-only
|
||
// (ktor-server-cio, litert-openai, ...). KMP-плагин тут
|
||
// только ради бесшовного потребления KMP-зависимостей (`:proto`,
|
||
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
|
||
kotlin {
|
||
jvmToolchain(21)
|
||
|
||
jvm {
|
||
binaries {
|
||
executable {
|
||
mainClass.set("pw.binom.agentik.standalone.MainKt")
|
||
}
|
||
}
|
||
}
|
||
|
||
sourceSets {
|
||
commonMain.dependencies {
|
||
// Протокол + KMP storage API
|
||
implementation(project(":proto"))
|
||
implementation(project(":journal-api"))
|
||
implementation(project(":outbox-api"))
|
||
implementation(project(":context-api"))
|
||
implementation(project(":reflection-api"))
|
||
|
||
// Commons
|
||
implementation(libs.kotlinx.coroutines.core)
|
||
implementation(libs.kotlinx.serialization.json)
|
||
|
||
// Парсер и загрузчик скилов (YAML frontmatter + markdown body).
|
||
implementation(project(":skills"))
|
||
|
||
// Долговременная память (Hermes-style MD-бэкенд) + хранилище истории.
|
||
implementation(project(":memory-api"))
|
||
implementation(project(":memory-md"))
|
||
if (!skipVectorMemory) {
|
||
implementation(project(":memory-vector"))
|
||
// Гибрид: .md (single source of truth) + ksqlite vector-кэш
|
||
// (sqlite-vec vec0). Подключается при AGENTIK_MEMORY_BACKEND=hybrid.
|
||
implementation(project(":memory-md-vector"))
|
||
}
|
||
|
||
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
|
||
implementation(project(":journal-ksqlite"))
|
||
implementation(project(":context-ksqlite"))
|
||
implementation(project(":reflection-ksqlite"))
|
||
// ksqlite объявлен как `implementation` (не `api`) в каждом
|
||
// ksqlite-модуле, поэтому SqliteStores здесь использует
|
||
// SQLiteConnection напрямую — фиксируем зависимость явно.
|
||
implementation(libs.ksqlite)
|
||
|
||
// Bounded-tail live event stream + per-event TTL.
|
||
implementation(project(":outbox-inmemory"))
|
||
// Персистентный счётчик событий (CursorHolder) поверх ksqlite —
|
||
// offset'ы переживают рестарт, клиент продолжает инкрементально.
|
||
implementation(project(":cursor-ksqlite"))
|
||
implementation(project(":cursor-inmemory"))
|
||
|
||
// :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider.
|
||
// ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.)
|
||
// добавляются через agent.install(...).
|
||
implementation(project(":agent-api"))
|
||
|
||
// LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner,
|
||
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
|
||
implementation(project(":agent-toolsets"))
|
||
implementation(project(":llm-tools"))
|
||
// Skill-подсистема как Component: тулы + SkillMiningComponent.
|
||
implementation(project(":skill-mining"))
|
||
|
||
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
||
implementation(project(":mcp-bridge"))
|
||
|
||
// Внешние мессенджер-интеграции. Подключаются как обычные
|
||
// :implementation (не api), чтобы при квант-like опциях в AppConfig
|
||
// можно было не класть их в classpath. Сейчас обе всегда включены —
|
||
// inactive-код стоит ~нулевых килобайт в fatjar (jar'ы маленькие),
|
||
// а опция «выключить интеграцию» живёт в `config.integrations.telegram`,
|
||
// не в classpath. См. README `:integrations-telegram`.
|
||
implementation(project(":integrations-telegram"))
|
||
|
||
// litert-kmp: контракт (commonMain)
|
||
api(libs.litert.api)
|
||
// liteTool DSL (типизированные LiteTool через @Serializable args)
|
||
implementation(libs.litert.tools.kotlinx.serialization)
|
||
// litert-openai: JVM-реализация
|
||
api(libs.litert.openai)
|
||
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
||
api(libs.litert.google)
|
||
|
||
// Ktor server (для A2A-фасада)
|
||
// Используем CIO вместо Netty — он KMP (jvm + linuxX64/ios/...), нам нужен
|
||
// для будущей native-сборки; на JVM работает идентично для нашего сценария.
|
||
implementation(libs.ktor.server.core)
|
||
implementation(libs.ktor.server.sse)
|
||
implementation(libs.ktor.server.cio)
|
||
implementation(libs.ktor.server.content.negotiation)
|
||
implementation(libs.ktor.serialization.kotlinx.json)
|
||
|
||
// A2A-фасад (agent↔agent). Proto user↔agent (:server) сюда НЕ подключается —
|
||
// standalone больше не выставляет :proto по сети.
|
||
implementation(libs.a2a.server)
|
||
|
||
// MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов
|
||
implementation(libs.mcp.sdk.client)
|
||
implementation(libs.ktor.client.core)
|
||
implementation(libs.ktor.client.cio)
|
||
implementation(libs.ktor.client.content.negotiation)
|
||
|
||
// Логирование: kotlin-logging (тонкая обёртка slf4j-api) + logback-classic
|
||
// (binding для JVM; без него slf4j-api NOP-логирует и не падает).
|
||
implementation(libs.kotlin.logging)
|
||
implementation(libs.logback.classic)
|
||
}
|
||
|
||
commonTest.dependencies {
|
||
implementation(libs.kotlinx.coroutines.core)
|
||
implementation(libs.kotlinx.coroutines.test)
|
||
implementation(libs.kotlinx.serialization.json)
|
||
implementation(libs.kotlin.test)
|
||
// Ktor server нужен ТОЛЬКО тестам: ModelDownloaderTest поднимает fake
|
||
// HTTP-сервер на CIO, чтобы проверять скачивание/resume/HEAD.
|
||
implementation(libs.ktor.server.core)
|
||
implementation(libs.ktor.server.cio)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Fatjar (uberjar) ---
|
||
//
|
||
// :standalone — KMP jvm-only бэкенд. Стандартный KMP `jvmJar` содержит только
|
||
// наши классы и требует подтянуть все зависимости на runtime через classpath.
|
||
// Для дистрибутива нужен self-contained `*-all.jar`, который содержит и наш
|
||
// код, и runtime classpath. Делается это Shadow-плагином:
|
||
//
|
||
// 1. shadowJar расширяет `jvmJar` (через from(tasks.named("jvmJar")))
|
||
// 2. Добавляет все артефакты из jvmRuntimeClasspath как `zipTree`
|
||
// 3. Корректно сливает META-INF/services (через ServiceFileTransformer)
|
||
// 4. Прописывает Main-Class в манифесте
|
||
//
|
||
// Результат: `build/libs/standalone-*-all.jar` запускается как
|
||
// java -jar standalone-0.1.0-all.jar
|
||
//
|
||
// KMP-сборка под ios/lin/macos не затрагивается — задача живёт только в jvm.
|
||
// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах (нет стандартного `jar`),
|
||
// поэтому регистрируем явно через tasks.register.
|
||
//
|
||
// В Gradle 9 KGP подменяет `configurations` на сгенерированный dependency-accessor
|
||
// (он возвращает List), поэтому используем прямой доступ к Project API.
|
||
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||
archiveBaseName.set("standalone")
|
||
archiveClassifier.set("all")
|
||
description = "Self-contained fatjar with all runtime dependencies bundled."
|
||
group = "build"
|
||
|
||
from(tasks.named("jvmJar"))
|
||
// javaClass-каст через Any? чтобы вытащить реальный ConfigurationContainer
|
||
// из любого типа, который сейчас прикидывается `configurations`.
|
||
val cc = try {
|
||
@Suppress("UNCHECKED_CAST")
|
||
configurations as ConfigurationContainer
|
||
} catch (_: ClassCastException) {
|
||
// KGP-generated dependency accessor; обходим через raw project.
|
||
@Suppress("UNCHECKED_CAST")
|
||
(project as org.gradle.api.Project).configurations as ConfigurationContainer
|
||
}
|
||
from(cc.getByName("jvmRuntimeClasspath"))
|
||
|
||
mergeServiceFiles()
|
||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||
|
||
manifest {
|
||
attributes["Main-Class"] = "pw.binom.agentik.standalone.MainKt"
|
||
attributes["Implementation-Title"] = "agentik-standalone"
|
||
attributes["Implementation-Version"] = project.version.toString()
|
||
}
|
||
|
||
includeEmptyDirs = false
|
||
}
|
||
|
||
// :standalone deploy
|
||
//
|
||
// `deployJar` собирает fatjar через shadowJar и копирует его на приватный
|
||
// домашний хост (root@192.168.76.166). Репа приватная, IP захардкожен —
|
||
// пользователь явно сказал "можно в наглую указать конкретно этот ip".
|
||
// Если понадобится другой хост — переопредели через -PagentikDeployHost=...
|
||
//
|
||
// Конвенция имени файла: agentik-standalone-{version}-all.jar (как на хосте
|
||
// уже лежат agentik-cli-0.1.0-all.jar / agentik-0.1.0-all.jar —
|
||
// выровнено по стилю существующего деплоя).
|
||
//
|
||
// SSH — ключевой, пароль не спрашивает. Если упадёт — проверь
|
||
// `ssh root@192.168.76.156 'echo OK'` вручную.
|
||
val agentikDeployHost: String =
|
||
(project.findProperty("agentikDeployHost") as? String) ?: "root@192.168.76.156"
|
||
val agentikDeployPath: String =
|
||
(project.findProperty("agentikDeployPath") as? String) ?: "~/agentik-standalone-${project.version}-all.jar"
|
||
|
||
tasks.register("deployJar") {
|
||
group = "deployment"
|
||
description = "Builds shadowJar and copies it to $agentikDeployHost:$agentikDeployPath"
|
||
dependsOn(shadowJarTask)
|
||
|
||
doLast {
|
||
val jar = shadowJarTask.get().outputs.files.singleFile
|
||
val target = "$agentikDeployHost:$agentikDeployPath"
|
||
println("→ scp $jar $target")
|
||
val proc = ProcessBuilder("scp", jar.absolutePath, target)
|
||
.redirectErrorStream(true)
|
||
.start()
|
||
proc.inputStream.bufferedReader().forEachLine { println("scp> $it") }
|
||
val rc = proc.waitFor()
|
||
if (rc != 0) {
|
||
throw GradleException("scp failed (exit=$rc). Check ssh access to $agentikDeployHost.")
|
||
}
|
||
println("✓ deployed to $target")
|
||
}
|
||
}
|