diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/NamedTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/NamedTool.kt similarity index 70% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/NamedTool.kt rename to agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/NamedTool.kt index c0f8814..e53f41a 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/NamedTool.kt +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/NamedTool.kt @@ -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) diff --git a/llm-tools/build.gradle.kts b/llm-tools/build.gradle.kts new file mode 100644 index 0000000..f2bd0d7 --- /dev/null +++ b/llm-tools/build.gradle.kts @@ -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) + } + } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ContextCompactor.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ContextCompactor.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ContextCompactor.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ContextCompactor.kt index bb13914..3fa7b73 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ContextCompactor.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ContextCompactor.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteConversation diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewer.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmMemoryReviewer.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewer.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmMemoryReviewer.kt index 3bd7fbd..3e6372c 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewer.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmMemoryReviewer.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent.memory +package pw.binom.agentik.llm.tools import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/LlmReflector.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmReflector.kt similarity index 97% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/LlmReflector.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmReflector.kt index b9b3381..53dd57b 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/LlmReflector.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/LlmReflector.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext @@ -27,7 +27,7 @@ import kotlin.time.Clock */ class LlmReflector( private val llm: LiteLlm, - private val maxTurns: Int = 6, + val maxTurns: Int = 6, private val maxTokens: Int = 512, private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val clock: Clock = Clock.System, diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionParser.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReflectionParser.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionParser.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReflectionParser.kt index 1abf967..d2d9d38 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionParser.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReflectionParser.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools /** * Минимальный парсер JSON-ответа от [LlmReflector]. diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionPrompts.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReflectionPrompts.kt similarity index 98% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionPrompts.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReflectionPrompts.kt index 42c9d54..b25886a 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ReflectionPrompts.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReflectionPrompts.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools import pw.binom.agentik.memory.ConversationTurn diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParser.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReviewDecisionParser.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParser.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReviewDecisionParser.kt index 3e355d6..be867a0 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParser.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReviewDecisionParser.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent.memory +package pw.binom.agentik.llm.tools import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryReviewDecision diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewPrompts.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReviewPrompts.kt similarity index 98% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewPrompts.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReviewPrompts.kt index facdc0d..49bd07e 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewPrompts.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/ReviewPrompts.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent.memory +package pw.binom.agentik.llm.tools import pw.binom.agentik.memory.ReviewedTurn diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiner.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiner.kt similarity index 98% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiner.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiner.kt index e36fa58..ccff70a 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiner.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiner.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParser.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningParser.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParser.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningParser.kt index d1aad83..bbc58c5 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParser.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningParser.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools import pw.binom.agentik.skills.SkillFile diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiningPrompts.kt b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningPrompts.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiningPrompts.kt rename to llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningPrompts.kt index 89058f6..6869536 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillMiningPrompts.kt +++ b/llm-tools/src/commonMain/kotlin/pw/binom/agentik/llm/tools/SkillMiningPrompts.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.agent +package pw.binom.agentik.llm.tools import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.skills.SkillFile diff --git a/mcp-bridge/build.gradle.kts b/mcp-bridge/build.gradle.kts new file mode 100644 index 0000000..1c3d71b --- /dev/null +++ b/mcp-bridge/build.gradle.kts @@ -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) +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpConfig.kt b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpConfig.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpConfig.kt rename to mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpConfig.kt index 07b8cb1..26a5800 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpConfig.kt +++ b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpConfig.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.mcp +package pw.binom.agentik.mcp.bridge import kotlinx.serialization.SerialName diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpRegistry.kt b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpRegistry.kt similarity index 99% rename from standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpRegistry.kt rename to mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpRegistry.kt index 16e3ea2..8d39b47 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpRegistry.kt +++ b/mcp-bridge/src/main/kotlin/pw/binom/agentik/mcp/bridge/McpRegistry.kt @@ -1,4 +1,4 @@ -package pw.binom.agentik.standalone.mcp +package pw.binom.agentik.mcp.bridge import mu.KotlinLogging @@ -30,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-серверов. diff --git a/settings.gradle.kts b/settings.gradle.kts index d50d126..0d1fe4a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,6 +28,14 @@ dependencyResolutionManagement { rootProject.name = "agentik" include(":standalone") +// Generic LLM-side tools: LlmReflector, SkillMiner, LlmMemoryReviewer, +// ContextCompactor + парсеры/промпты. Вынесены из :standalone (god class) +// — переиспользуемы в :agentik-cli / :agentik-tui и любых других клиентах. +include(":llm-tools") +// Generic MCP-bridge: McpConfig, McpRegistry, McpLiteToolAdapter. Вынесены +// из :standalone — MCP не специфичен для standalone'а, это generic мост +// между MCP-SDK и LiteTool. Содержит :agent-toolsets (NamedTool). +include(":mcp-bridge") // Собственный протокол agentik. Пока в нём пилим, потом вынесем. include(":proto") // Парсер скилов (YAML-frontmatter + markdown body, opencode-style). diff --git a/standalone/build.gradle.kts b/standalone/build.gradle.kts index 0ea3198..f7fc97b 100644 --- a/standalone/build.gradle.kts +++ b/standalone/build.gradle.kts @@ -59,6 +59,12 @@ kotlin { implementation(project(":storage-core")) implementation(project(":storage-sqlite")) implementation(project(":agent-toolsets")) + // Generic LLM-side tools (LlmReflector, SkillMiner, LlmMemoryReviewer, + // ContextCompactor, парсеры/промпты). Вынесены из :standalone. + implementation(project(":llm-tools")) + // Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter). + // Вынесен из :standalone — generic мост между MCP-SDK и LiteTool. + implementation(project(":mcp-bridge")) // litert-google: встроенный LiteRT-LM движок, нужен только на runtime runtimeOnly(libs.litert.google) diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt index aa380cb..de35192 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/DebugRoutes.kt @@ -11,8 +11,8 @@ import kotlinx.serialization.json.put import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.proto.Agent import pw.binom.agentik.standalone.agent.ChatConversation -import pw.binom.agentik.standalone.agent.LlmReflector -import pw.binom.agentik.standalone.agent.SkillMiner +import pw.binom.agentik.llm.tools.LlmReflector +import pw.binom.agentik.llm.tools.SkillMiner import pw.binom.agentik.standalone.agent.memory.Curator import pw.binom.agentik.storage.StorageBundle import pw.binom.agentik.skills.SkillStore diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt index f379734..278bd81 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -21,16 +21,17 @@ import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider import pw.binom.agentik.server.agentikAgent import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.standalone.agent.ChatAgent -import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor -import pw.binom.agentik.standalone.agent.LlmReflector -import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer -import pw.binom.agentik.standalone.config.AgentikConfig -import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend +import pw.binom.agentik.llm.tools.LiteLlmContextCompactor +import pw.binom.agentik.llm.tools.LlmReflector +import pw.binom.agentik.llm.tools.LlmMemoryReviewer +import pw.binom.agentik.standalone.config.AppConfig +import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.ModelDownloader -import pw.binom.agentik.standalone.mcp.McpRegistry +import pw.binom.agentik.mcp.bridge.McpRegistry import pw.binom.agentik.storage.sqlite.SqliteStores import java.io.File +import pw.binom.agentik.llm.tools.SkillMiner /** * standalone-контейнер agentik: * - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080) @@ -48,7 +49,7 @@ import java.io.File * GET /a2a/.well-known/agent-card.json -> AgentCard * GET /health -> "ok" * - * Вся конфигурация — [AgentikConfig.fromEnv] (см. [AgentikConfig]). Источники: + * Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники: * - AGENTIK_PORT / AGENTIK_DB_PATH * - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_* * - MCP: AGENTIK_MCP_CONFIG=.json (формат Claude Desktop) @@ -98,7 +99,7 @@ private fun printHelp() { * Если файл по PATH уже есть и совпадает по размеру с HEAD — no-op (exit 0). */ private fun runPullModel(args: List) { - val config = AgentikConfig.fromEnv() + val config = AppConfig.fromEnv() val google = config.llm.google ?: error("pull-model: требуется AGENTIK_LLM_BACKEND=google (сейчас ${config.llm.backend})") @@ -148,7 +149,7 @@ private fun formatBytes(b: Long): String = when { } private fun runServer() { - val config = AgentikConfig.fromEnv() + val config = AppConfig.fromEnv() // Перед созданием LLM: если backend=google и файл по AGENTIK_GOOGLE_MODEL_PATH // отсутствует — качаем автоматически (только при AGENTIK_AUTO_DOWNLOAD_MODEL=1), @@ -195,14 +196,14 @@ private fun runServer() { } val llm = config.llm.createLlm() - val storage = SqliteStores.open(dbPath = config.dbPath).asBundle() + val storage = SqliteStores.open(dbPath = config.agent.dbPath).asBundle() val mcpRegistry = McpRegistry.fromConfig(config.mcp) // Хранилище скилов: если skillsDir задан, читаем каталог + создаём // DiskSkillStore для self-improvement (`skill_save`/`skill_delete`). // Один и тот же файл-каталог используется и для чтения (read_skill), // и для записи — никаких рассинхронов. - val skillStore: pw.binom.agentik.skills.SkillStore? = config.skillsDir?.let { dir -> + val skillStore: pw.binom.agentik.skills.SkillStore? = config.agent.skillsDir?.let { dir -> pw.binom.agentik.skills.DiskSkillStore(File(dir)) } val skills = skillStore?.catalog ?: SkillCatalog.EMPTY @@ -210,18 +211,18 @@ private fun runServer() { // Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы, // которые модель забыла сохранить через `skill_save`. Работает только когда // есть куда писать (skillStore) и интервал > 0. - val skillMiner: pw.binom.agentik.standalone.agent.SkillMiner? = - if (skillStore != null && config.skillMiningInterval > 0) { - pw.binom.agentik.standalone.agent.SkillMiner( + val skillMiner: pw.binom.agentik.llm.tools.SkillMiner? = + if (skillStore != null && config.skillMining.interval > 0) { + pw.binom.agentik.llm.tools.SkillMiner( llm = llm, - maxTurns = config.skillMiningMaxTurns, + maxTurns = config.skillMining.maxTurns, ) } else null // SOUL.md — файл персоны. Если задан — читается как plain text/markdown, // вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0 // (на старте агента это фатально: нечего показывать LLM). - val soulBody = config.soulPath?.let { path -> + val soulBody = config.agent.soulPath?.let { path -> val file = File(path) if (!file.exists() || !file.isFile) { log.warn { "SOUL file not found: $path" } @@ -235,8 +236,8 @@ private fun runServer() { // - MD (дефолт) — Hermes-style §-файлы в AGENTIK_MEMORY_DIR (~/.agentik/memory) // - VECTOR — SQLite + JVector + LLM-эмбеддинги (тот же agentik.db для metadata) // - OFF — память выключена (memoryDir="off" или memoryBackend="off") - val rawMemory = config.memoryDir - val memorySystem: MemorySystem? = when (config.memoryBackend) { + val rawMemory = config.memory.dir + val memorySystem: MemorySystem? = when (config.memory.backend) { MemoryBackend.OFF -> { println(" memory: disabled") null @@ -253,8 +254,8 @@ private fun runServer() { } } MemoryBackend.VECTOR -> { - val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embeddingBackend) { - AgentikConfig.EmbeddingBackend.HTTP -> { + val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embedding.backend) { + AppConfig.EmbeddingBackend.HTTP -> { val llm = config.llm // Берём базовый URL + API key у активного LLM-бэкенда. // Поддерживается только OPENAI (LiteLLM proxy тоже работает, т.к. /v1/embeddings @@ -266,38 +267,38 @@ private fun runServer() { HttpEmbeddingClient( apiUrl = oa.baseUrl.trimEnd('/'), apiKey = oa.apiKey, - model = config.embeddingModel, - dimension = config.embeddingDimension, + model = config.embedding.model, + dimension = config.embedding.dimension, ) } - AgentikConfig.EmbeddingBackend.SIGLIP -> { - val modelPath = checkNotNull(config.embeddingModelPath) { + AppConfig.EmbeddingBackend.SIGLIP -> { + val modelPath = checkNotNull(config.embedding.modelPath) { "AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH" } - val tokenizerPath = checkNotNull(config.embeddingTokenizerPath) { + val tokenizerPath = checkNotNull(config.embedding.tokenizerPath) { "AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH" } SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath) } } VectorMemorySystem.open( - dbPath = config.dbPath, + dbPath = config.agent.dbPath, embedding = embedding, ).also { - val backendLabel = when (config.embeddingBackend) { - AgentikConfig.EmbeddingBackend.HTTP -> - "model=${config.embeddingModel}, dim=${config.embeddingDimension}" - AgentikConfig.EmbeddingBackend.SIGLIP -> + val backendLabel = when (config.embedding.backend) { + AppConfig.EmbeddingBackend.HTTP -> + "model=${config.embedding.model}, dim=${config.embedding.dimension}" + AppConfig.EmbeddingBackend.SIGLIP -> "model=siglip2-base (on-device), dim=${embedding.dimension}" } - println(" memory: db=${config.dbPath} (vector-backend, $backendLabel)") + println(" memory: db=${config.agent.dbPath} (vector-backend, $backendLabel)") } } } // Контекстное окно модели (для compaction'а working memory). // Если null — compaction выключен. Резолвится один раз из LlmConfig/env. - val contextWindow: Int? = config.llm.resolveContextWindow() + val contextWindow: Int? = config.llm.contextWindow val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null // Review-loop: всегда используем LlmMemoryReviewer поверх LiteLlm, если память включена. @@ -312,11 +313,11 @@ private fun runServer() { // Self-reflection: reflector работает только когда LLM доступен (нужен LiteLlm) // и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt. - val reflector: pw.binom.agentik.standalone.agent.LlmReflector? = - if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null + val reflector: pw.binom.agentik.llm.tools.LlmReflector? = + if (config.reflection.interval > 0) LlmReflector(llm = llm) else null val recentReflections: List = - if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking { - storage.reflectionStore.listRecent(config.reflectionTopK) + if (config.reflection.topK > 0) kotlinx.coroutines.runBlocking { + storage.reflectionStore.listRecent(config.reflection.topK) } else emptyList() val agent = ChatAgent( @@ -332,13 +333,11 @@ private fun runServer() { memoryReviewer = memoryReviewer, soulBody = soulBody, contextWindow = contextWindow, - compressionThreshold = config.compressionThreshold, + compressionThreshold = config.memory.compressionThreshold, contextCompactor = contextCompactor, recentReflections = recentReflections, reflector = reflector, - reflectionInterval = config.reflectionInterval, skillMiner = skillMiner, - skillMiningInterval = config.skillMiningInterval, ) // Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а, @@ -351,12 +350,12 @@ private fun runServer() { c } else null - val server = embeddedServer(CIO, port = config.port) { + val server = embeddedServer(CIO, port = config.agent.port) { routing { get("/health") { call.respondText("ok") } agentikAgent(agent, path = "/agentik") a2aAgent(agentName = "agentik", handler = A2aBridge(agent), path = "/a2a") - if (config.debugEndpoints) { + if (config.debug.endpoints) { debugRoutes( agent = agent, storage = storage, @@ -368,20 +367,20 @@ private fun runServer() { } } } - println("agentik standalone listening on http://localhost:${config.port}") + println("agentik standalone listening on http://localhost:${config.agent.port}") println(" GET /health") println(" POST /agentik/conversations -> 201") println(" GET /agentik/conversations/{id}/events -> SSE") println(" POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)") println(" GET /a2a/.well-known/agent-card.json -> AgentCard") - println(" storage: ${config.dbPath}") + println(" storage: ${config.agent.dbPath}") println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}") println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers") - println(" skills: ${skills.size} loaded${config.skillsDir?.let { " from $it" } ?: ""}") - if (config.soulPath != null) println(" soul: ${config.soulPath} (${soulBody?.length ?: 0} chars)") - println(" memory: ${if (memorySystem == null) "disabled" else "${config.memoryBackend.name.lowercase()}-backend"}") + println(" skills: ${skills.size} loaded${config.agent.skillsDir?.let { " from $it" } ?: ""}") + if (config.agent.soulPath != null) println(" soul: ${config.agent.soulPath} (${soulBody?.length ?: 0} chars)") + println(" memory: ${if (memorySystem == null) "disabled" else "${config.memory.backend.name.lowercase()}-backend"}") if (contextWindow != null) { - println(" compaction: enabled, threshold=${config.compressionThreshold}, window=$contextWindow tokens") + println(" compaction: enabled, threshold=${config.memory.compressionThreshold}, window=$contextWindow tokens") } else { println(" compaction: disabled (OPENAI_CONTEXT_WINDOW not set)") } @@ -389,9 +388,9 @@ private fun runServer() { println(" curator: enabled (interval=${pw.binom.agentik.standalone.agent.memory.Curator.DEFAULT_INTERVAL}, maxAge=${pw.binom.agentik.standalone.agent.memory.Curator.DEFAULT_MAX_AGE})") } if (skillMiner != null) { - println(" skill-mining: enabled (interval=${config.skillMiningInterval} turns, maxTurns=${config.skillMiningMaxTurns})") + println(" skill-mining: enabled (interval=${config.skillMining.interval} turns, maxTurns=${config.skillMining.maxTurns})") } - if (config.debugEndpoints) { + if (config.debug.endpoints) { println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)") } // Token stats по существующим диалогам (агрегат на старте — каждая запись diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundEvents.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundEvents.kt new file mode 100644 index 0000000..e54716c --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundEvents.kt @@ -0,0 +1,84 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Internal event bus для background work — separate from [ConversationEvents] + * (который это SSE-event stream для клиента). + * + * Background work fires на **structural events**, не на interval-polling: + * - **Review** — триггерится в CompactionCoordinator ПРЯМО ПЕРЕД удалением ходов + * из working memory (last chance вытащить факты). Это уже было сделано + * через `reviewer.reviewPreCompaction()` — оставляем как есть. + * - **Reflection** — на `ConversationLifecycleEvent.Closing` (финальная + * рефлексия перед закрытием) ИЛИ накопление N tool-failures в окне + * (что-то идёт не так). + * - **Skill mining** — на `CompactionEvent.Triggered` если `turnsToDelete > N` + * (есть контент для минера) ИЛИ на `ConversationLifecycleEvent.Closing`. + * + * Subscribers (BackgroundScheduler) решают, что делать. НЕ текстовая + * инспекция, НЕ regex — только структурные события с явным семантическим + * смыслом. См. STANDALONE-REVIEW раздел "event-driven background". + */ + +/** Эмитится из [ToolDispatcher] после каждого `runToolAndPersist` (success/failure). */ +sealed interface ToolCallEvent { + val toolName: String + + data class Succeeded( + override val toolName: String, + val durationMs: Long, + ) : ToolCallEvent + + data class Failed( + override val toolName: String, + val error: String, + ) : ToolCallEvent +} + +/** Эмитится из [CompactionCoordinator] ПЕРЕД `workingMemory.compact(...)`. */ +sealed interface CompactionEvent { + /** + * Compaction сейчас удалит N ходов из working memory. Background work + * имеет последний шанс вытащить оттуда данные. + */ + data class Triggered( + val turnsToDelete: Int, + val conversationId: String, + ) : CompactionEvent +} + +/** Эмитится из `ConversationLoop.close()` сразу ПЕРЕД `agentScope.cancel()`. */ +sealed interface ConversationLifecycleEvent { + data class Closing(val conversationId: String) : ConversationLifecycleEvent +} + +internal class BackgroundEventBus { + private val _toolCallEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val toolCallEvents: SharedFlow get() = _toolCallEvents.asSharedFlow() + + private val _compactionEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val compactionEvents: SharedFlow get() = _compactionEvents.asSharedFlow() + + private val _lifecycleEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val lifecycleEvents: SharedFlow get() = _lifecycleEvents.asSharedFlow() + + fun tryEmit(event: ToolCallEvent): Boolean = _toolCallEvents.tryEmit(event) + fun tryEmit(event: CompactionEvent): Boolean = _compactionEvents.tryEmit(event) + fun tryEmit(event: ConversationLifecycleEvent): Boolean = _lifecycleEvents.tryEmit(event) +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt index 873244b..a76e93f 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/BackgroundScheduler.kt @@ -1,153 +1,196 @@ package pw.binom.agentik.standalone.agent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import mu.KotlinLogging import pw.binom.agentik.memory.ConversationTurn -import pw.binom.agentik.memory.MemoryReviewDecision import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemoryStore -import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.storage.Content -import pw.binom.agentik.storage.MessageRecord import pw.binom.agentik.storage.ReflectionStore import pw.binom.agentik.storage.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryStore -import pw.binom.agentik.standalone.agent.memory.materializeReviewNote +import pw.binom.agentik.llm.tools.LlmReflector +import pw.binom.agentik.llm.tools.SkillMiner +import java.util.concurrent.atomic.AtomicLong +/** + * Background work подписчик на [BackgroundEventBus]. Заменяет старую interval-based + * логику (`maybeScheduleReview/Reflection/SkillMining` с `userTurnCount % N == 0`). + * + * Подписки: + * - [ConversationLifecycleEvent.Closing] → финальный reflection + skill mining + * перед закрытием conversation (last chance вытащить insights). + * - [CompactionEvent.Triggered] → skill mining если `turnsToDelete > MIN_COMPACTION_FOR_MINING`. + * Review уже сделан внутри CompactionCoordinator (`reviewPreCompaction`) — не дублируем. + * - [ToolCallEvent.Failed] (накопительно) → reflection если 2+ фейлов в окне 60 сек + * (что-то пошло не так — самоанализ полезен). + * + * НЕ текстовая инспекция, НЕ regex, НЕ interval-polling. Только структурные + * события с явным семантическим смыслом. + */ internal data class BackgroundConfig( val memoryReviewer: MemoryReviewer?, val memoryStore: MemoryStore?, - val memoryReviewInterval: Int = 0, val reflectionStore: ReflectionStore?, val reflector: LlmReflector?, - val reflectionInterval: Int, val skillMiner: SkillMiner?, val skillMiningStore: SkillStore?, - val skillMiningInterval: Int, ) internal class BackgroundScheduler( private val state: ConversationState, private val workingMemory: WorkingMemoryStore, private val config: BackgroundConfig, + private val backgroundEvents: BackgroundEventBus, ) { private val log = KotlinLogging.logger {} - fun maybeScheduleReview( - userRecord: MessageRecord.UserMessage, - assistantContent: List, - ) { - val reviewer = config.memoryReviewer ?: return - val store = config.memoryStore ?: return - if (state.isTemporal) return - if (config.memoryReviewInterval > 0) { - val userTurnCount = countUserTurnsBlocking() - if (userTurnCount % config.memoryReviewInterval != 0) return - } - val userText = userRecord.content.filterIsInstance() - .joinToString("\n") { it.body } - val assistantText = assistantContent.filterIsInstance() - .joinToString("\n") { it.body } - if (userText.isBlank() || assistantText.isBlank()) return - val convId = state.id - state.agentScope.launch { - try { - val decision: MemoryReviewDecision = reviewer.review( - ReviewedTurn( - userMessage = userText, - assistantMessage = assistantText, - conversationId = convId, - ), - ) - for (n in decision.toSave) { - val note = materializeReviewNote(n, conversationId = null) - runCatching { store.upsert(note) } - .onFailure { log.warn(it) { "review upsert failed: ${it.message}" } } - } - for (id in decision.toDelete) { - runCatching { store.delete(id) } - .onFailure { log.warn(it) { "review delete failed: ${it.message}" } } - } - } catch (e: Throwable) { - log.warn(e) { "review failed for $convId: ${e.message}" } - } + private val lastSkillMiningAt = AtomicLong(0) + private val lastReflectionAt = AtomicLong(0) + + /** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */ + private val toolFailures = mutableListOf() + private val toolFailuresLock = Any() + + private var subscriptionJob: Job? = null + + /** Запустить подписки. Вызывать один раз после конструктора. */ + fun start(scope: CoroutineScope) { + if (subscriptionJob?.isActive == true) return + subscriptionJob = scope.launch { + // Merge all three event flows into one subscription scope. Each onEach + // returns Unit, so launchIn merges them as cold flows. + merge( + backgroundEvents.lifecycleEvents + .filterIsInstance() + .onEach { onClosing() }, + backgroundEvents.compactionEvents + .filterIsInstance() + .onEach { onCompaction(it) }, + backgroundEvents.toolCallEvents + .filterIsInstance() + .onEach { onToolFailure() }, + ).collect {} } } - fun maybeScheduleReflection( - userRecord: MessageRecord.UserMessage, - assistantContent: List, - ) { - if (config.reflectionInterval <= 0) return + private suspend fun onClosing() { + if (state.isTemporal) return + val convId = state.id + runFinalReflection(convId) + runFinalSkillMining(convId) + } + + private fun runFinalReflection(convId: String) { val reflector = config.reflector ?: return val store = config.reflectionStore ?: return - if (state.isTemporal) return - val userText = userRecord.content.filterIsInstance() - .joinToString("\n") { it.body } - val assistantText = assistantContent.filterIsInstance() - .joinToString("\n") { it.body } - if (userText.isBlank() || assistantText.isBlank()) return - val userTurnCount = countUserTurnsBlocking() - if (userTurnCount % config.reflectionInterval != 0) return - val convId = state.id state.agentScope.launch { try { - val turns = listOf( - ConversationTurn( - userMessage = userText, - assistantMessage = assistantText, - ) - ) + val turns = recentTurns(reflector.maxTurns) + if (turns.isEmpty()) return@launch val reflection = reflector.reflect(turns) ?: return@launch - val stamped = reflection.copy(conversationId = convId) - runCatching { store.insert(stamped) } - .onFailure { log.warn(it) { "reflection insert failed: ${it.message}" } } - log.info { "self-reflection score=${stamped.score}/5 conv=$convId spots=${stamped.weakSpots.size}" } + runCatching { store.insert(reflection.copy(conversationId = convId)) } + .onFailure { log.warn(it) { "final reflection insert failed: ${it.message}" } } + log.info { "final reflection on close: conv=$convId score=${reflection.score}/5" } } catch (e: Throwable) { - log.warn(e) { "reflection failed for $convId: ${e.message}" } + log.warn(e) { "final reflection failed for $convId: ${e.message}" } } } } - fun maybeScheduleSkillMining( - userRecord: MessageRecord.UserMessage, - assistantContent: List, - ) { - if (config.skillMiningInterval <= 0) return + private fun runFinalSkillMining(convId: String) { val miner = config.skillMiner ?: return val store = config.skillMiningStore ?: return - if (state.isTemporal) return - val userTurnCount = countUserTurnsBlocking() - if (userTurnCount % config.skillMiningInterval != 0) return - val convId = state.id state.agentScope.launch { try { - val turns = recentTurnsFromWorkingMemory(miner.maxTurns) + val turns = recentTurns(miner.maxTurns) if (turns.isEmpty()) return@launch - val existing = store.catalog.skills - val mined = miner.mine(turns, existing) + val mined = miner.mine(turns, store.catalog.skills) for (s in mined) { runCatching { store.upsert(s) } - .onFailure { log.warn(it) { "skill-mine upsert '${s.name}' failed: ${it.message}" } } + .onFailure { log.warn(it) { "final skill mining upsert '${s.name}' failed: ${it.message}" } } } - log.info { "skill-mine: conv=$convId turns=${turns.size} existing=${existing.size} mined=${mined.size}" } + log.info { "final skill mining on close: conv=$convId turns=${turns.size} existing=${store.catalog.skills.size} mined=${mined.size}" } } catch (e: Throwable) { - log.warn(e) { "skill-mine failed for $convId: ${e.message}" } + log.warn(e) { "final skill mining failed for $convId: ${e.message}" } } } } - private fun countUserTurnsBlocking(): Int = runBlocking { - var count = 0 - for (row in workingMemory.list(state.id)) { - if (row.entry is WorkingMemoryEntry.User) count++ + private fun onCompaction(event: CompactionEvent.Triggered) { + if (state.isTemporal) return + if (event.turnsToDelete < MIN_COMPACTION_FOR_MINING) return + val miner = config.skillMiner ?: return + val store = config.skillMiningStore ?: return + val now = System.currentTimeMillis() + // Debounce: не чаще раза в минуту + if (now - lastSkillMiningAt.get() < MINING_DEBOUNCE_MS) return + lastSkillMiningAt.set(now) + val convId = event.conversationId + state.agentScope.launch { + try { + val turns = recentTurns(miner.maxTurns) + if (turns.isEmpty()) return@launch + val mined = miner.mine(turns, store.catalog.skills) + for (s in mined) { + runCatching { store.upsert(s) } + .onFailure { log.warn(it) { "compaction skill mining upsert '${s.name}' failed: ${it.message}" } } + } + log.info { "compaction skill mining: conv=$convId turnsToDelete=${event.turnsToDelete} mined=${mined.size}" } + } catch (e: Throwable) { + log.warn(e) { "compaction skill mining failed for $convId: ${e.message}" } + } } - count } - private suspend fun recentTurnsFromWorkingMemory(maxTurns: Int): List { + private fun onToolFailure() { + val reflector = config.reflector ?: return + if (state.isTemporal) return + val store = config.reflectionStore ?: return + + val now = System.currentTimeMillis() + val shouldReflect = synchronized(toolFailuresLock) { + toolFailures.add(now) + // Trim old failures outside the window + val cutoff = now - FAILURE_WINDOW_MS + toolFailures.removeAll { it < cutoff } + toolFailures.size >= FAILURE_THRESHOLD + } + if (!shouldReflect) return + + // Debounce reflection globally (не чаще раза в 5 мин) + if (now - lastReflectionAt.get() < REFLECTION_DEBOUNCE_MS) { + log.debug { "reflection debounced: ${toolFailures.size} failures accumulated but reflection fired recently" } + return + } + lastReflectionAt.set(now) + + // Clear failure window — fresh accounting period + synchronized(toolFailuresLock) { toolFailures.clear() } + + val convId = state.id + state.agentScope.launch { + try { + val turns = recentTurns(reflector.maxTurns) + if (turns.isEmpty()) return@launch + val reflection = reflector.reflect(turns) ?: return@launch + runCatching { store.insert(reflection.copy(conversationId = convId)) } + .onFailure { log.warn(it) { "reflection after failures insert failed: ${it.message}" } } + log.info { "reflection triggered by tool failures: conv=$convId score=${reflection.score}/5" } + } catch (e: Throwable) { + log.warn(e) { "reflection after tool failures failed for $convId: ${e.message}" } + } + } + } + + private suspend fun recentTurns(maxTurns: Int): List { val rows = workingMemory.list(state.id) val pairs = mutableListOf() var pendingUser: String? = null @@ -167,4 +210,17 @@ internal class BackgroundScheduler( private fun List.text(): String = filterIsInstance().joinToString("\n") { it.body } + + companion object { + /** Минимум ходов, удаляемых compaction'ом, чтобы trigger'ить skill mining. */ + private const val MIN_COMPACTION_FOR_MINING = 10 + /** Дебаунс skill mining между запусками. */ + private const val MINING_DEBOUNCE_MS = 60_000L + /** Debounce reflection между запусками (накопительный, не per-failure). */ + private const val REFLECTION_DEBOUNCE_MS = 300_000L + /** Сколько tool-failures в окне должно накопиться чтобы trigger reflection. */ + private const val FAILURE_THRESHOLD = 2 + /** Окно для accumulation tool-failures. */ + private const val FAILURE_WINDOW_MS = 60_000L + } } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index dd987da..e377d4f 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -28,6 +28,11 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.agentik.toolsets.ToolsetRegistry import pw.binom.litert.LiteLlm import kotlin.time.Instant +import pw.binom.agentik.llm.tools.LlmReflector +import pw.binom.agentik.llm.tools.SkillMiner +import pw.binom.agentik.llm.tools.LlmMemoryReviewer +import pw.binom.agentik.llm.tools.ContextCompactor +import pw.binom.agentik.toolsets.NamedTool /** * Stateful [ProtoAgent] на базе SQLite (история + working memory) и @@ -66,14 +71,6 @@ class ChatAgent( private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null, private val memoryPrefetcher: MemoryPrefetcher? = null, private val memoryReviewer: MemoryReviewer? = null, - /** - * Через сколько пользовательских ходов запускать LLM-based memory review - * (см. [pw.binom.agentik.standalone.agent.LlmMemoryReviewer]). `0` — - * review выключен. Default: 0 (для безопасности — старый код без - * interval-gate приводил к ×2 LLM-call amplification, и [Main.kt] явно - * передаёт config.memoryReviewInterval). - */ - private val memoryReviewInterval: Int = 0, /** * Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало * системного промпта, поверх базы, навыков и memory-guidance. `null` — @@ -103,19 +100,11 @@ class ChatAgent( */ private val reflector: LlmReflector? = null, /** - * Через сколько пользовательских ходов запускать рефлексию. `0` = выключено. - */ - private val reflectionInterval: Int = 0, - /** - * Фоновый минер скилов: каждые N ходов LLM смотрит последние ходы и - * upsert-ит переиспользуемые скилы в [skillStore]. `null` = mining выключен. + * Фоновый минер скилов: LLM-вызов, который запускается на compaction + * (`turnsToDelete > 10`) или при closing conversation. `null` = mining выключен. * Сетка безопасности, если модель забыла вызвать `skill_save` сама. */ private val skillMiner: SkillMiner? = null, - /** - * Через сколько пользовательских ходов запускать skill mining. `0` = выключено. - */ - private val skillMiningInterval: Int = 0, /** * Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает * о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются, @@ -242,16 +231,13 @@ class ChatAgent( memoryPrefetcher = memoryPrefetcher, memoryReviewer = memoryReviewer, memoryStoreForReview = memoryStore, - memoryReviewInterval = memoryReviewInterval, contextWindow = contextWindow, compressionThreshold = compressionThreshold, contextCompactor = contextCompactor, reflectionStore = storage.reflectionStore, reflector = reflector, - reflectionInterval = reflectionInterval, skillMiner = skillMiner, skillMiningStore = skillStore, - skillMiningInterval = skillMiningInterval, ) runBlocking { liveLock.withLock { live[conv.id] = conv } @@ -294,16 +280,13 @@ class ChatAgent( memoryPrefetcher = memoryPrefetcher, memoryReviewer = memoryReviewer, memoryStoreForReview = memoryStore, - memoryReviewInterval = memoryReviewInterval, contextWindow = contextWindow, compressionThreshold = compressionThreshold, contextCompactor = contextCompactor, reflectionStore = storage.reflectionStore, reflector = reflector, - reflectionInterval = reflectionInterval, skillMiner = skillMiner, skillMiningStore = skillStore, - skillMiningInterval = skillMiningInterval, ) override fun close() { diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt index 6b9dd1d..a689fb8 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/CompactionCoordinator.kt @@ -16,6 +16,8 @@ import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteLlm import pw.binom.litert.LiteMessage import pw.binom.litert.LiteRole +import pw.binom.agentik.llm.tools.ContextCompactor +import pw.binom.agentik.llm.tools.SummaryTurn internal class CompactionCoordinator( private val state: ConversationState, @@ -27,6 +29,7 @@ internal class CompactionCoordinator( private val workingMemory: WorkingMemoryStore, private val liteLlm: LiteLlm, private val systemPrompt: String, + private val backgroundEvents: BackgroundEventBus, ) { private val log = KotlinLogging.logger {} @@ -63,6 +66,12 @@ internal class CompactionCoordinator( } if (toCompact.isEmpty()) return false + // Emit CompactionEvent BEFORE deletion. BackgroundScheduler может trigger'ить + // skill mining на основе turnsToDelete (есть контент — есть что майнить). + backgroundEvents.tryEmit( + CompactionEvent.Triggered(turnsToDelete = toCompact.size, conversationId = state.id), + ) + val turns = toCompact.mapNotNull { row -> when (val e = row.entry) { is WorkingMemoryEntry.User -> SummaryTurn( @@ -225,6 +234,8 @@ internal class CompactionCoordinator( companion object { private const val KEEP_RECENT_TURNS = 4 private const val MAX_SEEDED_MESSAGES = 50 + /** Минимум удаляемых ходов чтобы BackgroundScheduler trigger'ил skill mining. */ + private const val MIN_COMPACTION_FOR_MINING = 10 } } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt index bbea7be..8375ace 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationLoop.kt @@ -48,6 +48,10 @@ import pw.binom.litert.LiteTool import pw.binom.litert.LiteToolCall import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Instant +import pw.binom.agentik.llm.tools.LlmReflector +import pw.binom.agentik.llm.tools.SkillMiner +import pw.binom.agentik.llm.tools.ContextCompactor +import pw.binom.agentik.toolsets.NamedTool class ConversationLoop( record: ConversationRecord, @@ -59,16 +63,13 @@ class ConversationLoop( private val memoryPrefetcher: MemoryPrefetcher? = null, private val memoryReviewer: MemoryReviewer? = null, private val memoryStoreForReview: MemoryStore? = null, - private val memoryReviewInterval: Int = 0, private val contextWindow: Int? = null, private val compressionThreshold: Double = 0.8, private val contextCompactor: ContextCompactor? = null, private val reflectionStore: ReflectionStore? = null, private val reflector: LlmReflector? = null, - private val reflectionInterval: Int = 0, private val skillMiner: SkillMiner? = null, private val skillMiningStore: SkillStore? = null, - private val skillMiningInterval: Int = 0, ) : ProtoConversation, AutoCloseable { private val log = KotlinLogging.logger {} @@ -85,6 +86,9 @@ class ConversationLoop( private val events = ConversationEvents() + /** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */ + private val backgroundEvents = BackgroundEventBus() + private val conversationStore: ConversationStore get() = storage.conversationStore private val messageStore: MessageStore get() = storage.messageStore private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore @@ -103,12 +107,14 @@ class ConversationLoop( workingMemory = workingMemory, liteLlm = llm, systemPrompt = systemPrompt, + backgroundEvents = backgroundEvents, ) private val toolDispatcher = ToolDispatcher( state = state, messageStore = messageStore, events = events, + backgroundEvents = backgroundEvents, toolsByName = toolsByName, toolsetDispatch = toolsetDispatch, newId = ::newId, @@ -122,15 +128,13 @@ class ConversationLoop( config = BackgroundConfig( memoryReviewer = memoryReviewer, memoryStore = memoryStoreForReview, - memoryReviewInterval = memoryReviewInterval, reflectionStore = reflectionStore, reflector = reflector, - reflectionInterval = reflectionInterval, skillMiner = skillMiner, skillMiningStore = skillMiningStore, - skillMiningInterval = skillMiningInterval, ), - ) + backgroundEvents = backgroundEvents, + ).also { it.start(agentScope) } override val id: String get() = state.id override val isSupportImageInput: Boolean get() = false @@ -210,6 +214,10 @@ class ConversationLoop( override fun close() { if (state.isClosed) return state.markClosed() + // Emit Closing event BEFORE agentScope.cancel() — BackgroundScheduler's подписка + // ловит это и делает final reflection + skill mining (last chance вытащить insights). + // После cancel() подписка умерла бы. + backgroundEvents.tryEmit(ConversationLifecycleEvent.Closing(conversationId = id)) state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } } runCatching { runBlocking { activeTurn?.cancelAndJoin() } } agentScope.cancel() @@ -420,9 +428,9 @@ class ConversationLoop( state.record = state.record.copy(updatedAt = assistantAt) conversationStore.touch(id, assistantAt) - backgroundScheduler.maybeScheduleReview(userRecord, assistantContent) - backgroundScheduler.maybeScheduleReflection(userRecord, assistantContent) - backgroundScheduler.maybeScheduleSkillMining(userRecord, assistantContent) + // BackgroundScheduler is event-driven — подписан на BackgroundEventBus + // (compaction/lifecycle/tool-failure events). Никаких interval-based + // вызовов сюда больше не идёт. См. BackgroundScheduler.kt. } } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt index aade33a..e979cf6 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ConversationState.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import pw.binom.agentik.storage.ConversationRecord import pw.binom.litert.LiteConversation import java.util.concurrent.atomic.AtomicReference +import pw.binom.agentik.toolsets.NamedTool internal class ConversationState( initialRecord: ConversationRecord, diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt index 0e2b09c..54d26e6 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/SkillToolsFactory.kt @@ -1,6 +1,7 @@ package pw.binom.agentik.standalone.agent import pw.binom.agentik.skills.SkillStore +import pw.binom.agentik.toolsets.NamedTool /** * Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style). diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt index 18ceeef..1b23bd8 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ToolDispatcher.kt @@ -12,11 +12,13 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.litert.LiteToolCall import pw.binom.litert.LiteTool import kotlin.time.Instant +import pw.binom.agentik.toolsets.NamedTool internal class ToolDispatcher( private val state: ConversationState, private val messageStore: MessageStore, private val events: ConversationEvents, + private val backgroundEvents: BackgroundEventBus, private val toolsByName: MutableMap, private val toolsetDispatch: ToolsetDispatchPolicy?, private val newId: (String) -> String, @@ -39,6 +41,7 @@ internal class ToolDispatcher( val resultId = newId("tr") val argsJson = encodeArgsJson(call.arguments) val nowTs = now() + val startMs = System.currentTimeMillis() events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson)) @@ -74,6 +77,7 @@ internal class ToolDispatcher( } _currentToolJob = toolDeferred + var failureError: String? = null val resultText: String = try { toolDeferred.await() } catch (e: CancellationException) { @@ -82,7 +86,8 @@ internal class ToolDispatcher( "[cancelled by user]" } catch (e: Throwable) { log.warn(e) { "tool '${call.name}' threw: ${e.message}" } - "[tool error: ${e.message ?: e.javaClass.simpleName}]" + failureError = e.message ?: e.javaClass.simpleName + "[tool error: ${failureError}]" } finally { _currentToolJob = null } @@ -90,6 +95,15 @@ internal class ToolDispatcher( val resultAt = now() events.tryEmit(ProtoEvent.ToolResult(date = resultAt, id = resultId, result = resultText)) + // Эмитим background event — другие компоненты (BackgroundScheduler) + // решают, делать ли что-то. Cancellation = not a failure (не эмитим Failed). + val durationMs = System.currentTimeMillis() - startMs + if (failureError != null) { + backgroundEvents.tryEmit(ToolCallEvent.Failed(toolName = call.name, error = failureError)) + } else if (resultText != "[cancelled by user]") { + backgroundEvents.tryEmit(ToolCallEvent.Succeeded(toolName = call.name, durationMs = durationMs)) + } + if (!state.isTemporal) { messageStore.append( MessageRecord.ToolResult( diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt index ecd71b9..95833c5 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryTools.kt @@ -22,6 +22,7 @@ import pw.binom.agentik.memory.NewMemoryNote import java.util.UUID import kotlin.time.Clock import kotlinx.serialization.json.jsonObject +import pw.binom.agentik.toolsets.NamedTool private val MemoryToolsJson = Json { ignoreUnknownKeys = true; isLenient = true } private val MemoryResponseJson = Json { encodeDefaults = true } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt index f489403..fa1203b 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/memory/MemoryToolsFactory.kt @@ -2,8 +2,8 @@ package pw.binom.agentik.standalone.agent.memory import kotlinx.coroutines.runBlocking import pw.binom.agentik.memory.MemoryStore -import pw.binom.agentik.standalone.agent.NamedTool import pw.binom.litert.LiteTool +import pw.binom.agentik.toolsets.NamedTool /** * Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete) diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt deleted file mode 100644 index cbd49d3..0000000 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt +++ /dev/null @@ -1,178 +0,0 @@ -package pw.binom.agentik.standalone.config - -import kotlinx.serialization.Serializable -import pw.binom.agentik.standalone.llm.LlmConfig -import pw.binom.agentik.standalone.mcp.McpConfig - -/** - * Полная конфигурация standalone-рантайма — единая точка входа для всего, - * что настраивается снаружи: HTTP-порт, путь к БД, LLM (бэкенд/модель/ключи) - * и MCP-серверы. - * - * DTO иммутабельный, сериализуемый и не зависит от источника. Сейчас - * наполняется из переменных среды ([fromEnv]); позже появится загрузка из - * файла (yaml) — тогда файл будет строиться в такой же [AgentikConfig], а env - * станет override'ом. - * - * Потребители ([pw.binom.agentik.standalone.Main]) читают только этот тип и - * вложенные [llm]/[mcp] — прямых `System.getenv` вне `fromEnv` быть не должно. - */ -@Serializable -data class AgentikConfig( - val port: Int = DEFAULT_PORT, - val dbPath: String = DEFAULT_DB_PATH, - val llm: LlmConfig, - val mcp: McpConfig = McpConfig.empty(), - /** Папка со скилами (SKILL.md / *.yaml). `null` — скилы выключены. */ - val skillsDir: String? = null, - /** - * Корневая директория памяти (Hermes-style §-файлы). `null` — память - * включается на дефолте `~/.agentik/memory`. Спецзначение `"off"` — - * память выключена (тулы memory_* не регистрируются, prefetch отключён). - */ - val memoryDir: String? = null, - /** - * Путь к SOUL.md — файл с описанием персоны ассистента (markdown body). - * Содержимое вставляется в самое начало `systemInstruction` поверх - * базового промпта, секции навыков и memory-guidance. `null` — файл не - * читается, секция не добавляется. - */ - val soulPath: String? = null, - /** - * Порог compaction'а working memory: доля от contextWindow, при которой - * запускается суммаризация старых ходов. Дефолт `0.8` (80%). Чем меньше — - * тем раньше начинаем сжимать (безопаснее для больших ассистентских - * ответов, но больше токенов уходит на compaction-вызовы). - * - * Если `contextWindow == null` (не задан через `OPENAI_CONTEXT_WINDOW`) — - * compaction не запускается вне зависимости от threshold. - */ - val compressionThreshold: Double = DEFAULT_COMPRESSION_THRESHOLD, - /** - * Бэкенд долговременной памяти. - * - [MemoryBackend.MD] — Hermes-style §-файлы (keyword overlap). - * - [MemoryBackend.VECTOR] — SQLite + JVector + LLM-эмбеддинги. - * - [MemoryBackend.OFF] — память выключена (`AGENTIK_MEMORY_DIR=off`). - */ - val memoryBackend: MemoryBackend = MemoryBackend.MD, - /** - * Имя модели эмбеддингов для vector-бэкенда. Используется только при - * [embeddingBackend] = HTTP. Дефолт `text-embedding-3-small` - * (1536-мерный). Должна быть доступна через тот же baseUrl/apiKey что и LLM. - */ - val embeddingModel: String = DEFAULT_EMBEDDING_MODEL, - /** - * Размерность эмбеддингов vector-бэкенда. Используется только при - * [embeddingBackend] = HTTP. Должна совпадать с реальной размерностью - * [embeddingModel]. Дефолт 1536 для `text-embedding-3-small`. - * Для [embeddingBackend] = SIGLIP размерность определяется самой моделью - * (768 для SigLIP2-base), параметр игнорируется. - */ - val embeddingDimension: Int = DEFAULT_EMBEDDING_DIMENSION, - /** - * Бэкенд эмбеддингов для vector-памяти: - * - HTTP — POST /v1/embeddings к OpenAI-совместимому API (default); - * - SIGLIP — on-device SigLIP2 через ONNX Runtime (text-embedding-kmp), без сети. - */ - val embeddingBackend: EmbeddingBackend = EmbeddingBackend.HTTP, - /** - * Путь к ONNX-модели SigLIP2 (`text_model_int8.onnx`). Используется только при - * [embeddingBackend] = SIGLIP. - */ - val embeddingModelPath: String? = null, - /** - * Путь к sentencepiece-токенизатору (`tokenizer.model`). Используется только при - * [embeddingBackend] = SIGLIP. - */ - val embeddingTokenizerPath: String? = null, - /** - * Через сколько пользовательских ходов запускать self-reflection. - * `0` или `null` — отключает reflection. Default: 10. - * См. [LlmReflector]. - */ - val reflectionInterval: Int = DEFAULT_REFLECTION_INTERVAL, - /** - * Сколько последних reflection-записей подмешивать в system prompt. - * Default: 3. `0` — не подмешивать. - */ - val reflectionTopK: Int = DEFAULT_REFLECTION_TOP_K, - /** - * Через сколько пользовательских ходов запускать skill mining (фоновый - * LLM-прогон, который находит переиспользуемые скилы, которые модель - * забыла сохранить через `skill_save`). `0` — mining выключен. Default: 15. - * См. [pw.binom.agentik.standalone.agent.SkillMiner]. - */ - val skillMiningInterval: Int = DEFAULT_SKILL_MINING_INTERVAL, - /** - * Сколько последних ходов передавать skill-miner'у за один прогон. - * Default: 30. - */ - val skillMiningMaxTurns: Int = DEFAULT_SKILL_MINING_MAX_TURNS, - /** - * Включает debug-эндпоинты (`/debug/reflect`, `/debug/skill-mine`, - * `/debug/curate`, `/debug/compact`, `/debug/tokens`) для ручного - * триггерирования фоновых фич без ожидания интервалов. Только локальная - * отладка: `AGENTIK_DEBUG_ENDPOINTS=1`. - */ - val debugEndpoints: Boolean = false, -) { - /** Бэкенд долговременной памяти. */ - @Serializable - enum class MemoryBackend { MD, VECTOR, OFF } - - /** Бэкенд эмбеддингов (для memory-backend=vector). */ - @Serializable - enum class EmbeddingBackend { HTTP, SIGLIP } - - companion object { - const val DEFAULT_PORT: Int = 8080 - const val DEFAULT_DB_PATH: String = "./agentik.db" - const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8 - const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small" - const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536 - const val DEFAULT_REFLECTION_INTERVAL: Int = 10 - const val DEFAULT_REFLECTION_TOP_K: Int = 3 - const val DEFAULT_SKILL_MINING_INTERVAL: Int = 15 - const val DEFAULT_SKILL_MINING_MAX_TURNS: Int = 30 - - /** - * Читает конфигурацию из переменных среды. - * - * @param env абстракция доступа к env (для тестов подменяется lambda). - */ - fun fromEnv(env: (String) -> String? = System::getenv): AgentikConfig = AgentikConfig( - port = env("AGENTIK_PORT")?.toIntOrNull() ?: DEFAULT_PORT, - dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH, - llm = LlmConfig.fromEnv(env), - mcp = McpConfig.fromEnv(env), - skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() }, - memoryDir = env("AGENTIK_MEMORY_DIR")?.takeIf { it.isNotBlank() }, - soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() }, - compressionThreshold = env("AGENTIK_COMPRESSION_THRESHOLD")?.toDoubleOrNull() - ?.coerceIn(0.1, 0.99) ?: DEFAULT_COMPRESSION_THRESHOLD, - memoryBackend = env("AGENTIK_MEMORY_BACKEND")?.let { - runCatching { MemoryBackend.valueOf(it.uppercase()) }.getOrNull() - } ?: MemoryBackend.MD, - embeddingModel = env("AGENTIK_EMBEDDING_MODEL")?.takeIf { it.isNotBlank() } - ?: DEFAULT_EMBEDDING_MODEL, - embeddingDimension = env("AGENTIK_EMBEDDING_DIMENSION")?.toIntOrNull() - ?: DEFAULT_EMBEDDING_DIMENSION, - embeddingBackend = env("AGENTIK_EMBEDDING_BACKEND")?.let { - runCatching { EmbeddingBackend.valueOf(it.uppercase()) }.getOrNull() - } ?: EmbeddingBackend.HTTP, - embeddingModelPath = env("AGENTIK_EMBEDDING_MODEL_PATH")?.takeIf { it.isNotBlank() }, - embeddingTokenizerPath = env("AGENTIK_EMBEDDING_TOKENIZER_PATH")?.takeIf { it.isNotBlank() }, - reflectionInterval = env("AGENTIK_REFLECTION_INTERVAL")?.toIntOrNull() - ?.coerceIn(0, 1000) ?: DEFAULT_REFLECTION_INTERVAL, - reflectionTopK = env("AGENTIK_REFLECTION_TOP_K")?.toIntOrNull() - ?.coerceIn(0, 20) ?: DEFAULT_REFLECTION_TOP_K, - skillMiningInterval = env("AGENTIK_SKILL_MINING_INTERVAL")?.toIntOrNull() - ?.coerceIn(0, 1000) ?: DEFAULT_SKILL_MINING_INTERVAL, - skillMiningMaxTurns = env("AGENTIK_SKILL_MINING_MAX_TURNS")?.toIntOrNull() - ?.coerceIn(1, 1000) ?: DEFAULT_SKILL_MINING_MAX_TURNS, - debugEndpoints = env("AGENTIK_DEBUG_ENDPOINTS")?.let { - it.equals("1", ignoreCase = true) || it.equals("true", ignoreCase = true) - } ?: false, - ) - } -} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt new file mode 100644 index 0000000..db32de6 --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AppConfig.kt @@ -0,0 +1,222 @@ +package pw.binom.agentik.standalone.config + +import kotlinx.serialization.Serializable + +import pw.binom.agentik.standalone.llm.GoogleConfig +import pw.binom.agentik.standalone.llm.LlmBackend +import pw.binom.agentik.standalone.llm.LlmConfig +import pw.binom.agentik.standalone.llm.OpenAiConfig +import pw.binom.agentik.mcp.bridge.McpConfig + +// Inlined here (AppLimits.kt удалён параллельным рефакторингом): сетевые капы +// и env-parser limits живут рядом с тем, кто их использует, чтобы :config не +// зависел от удалённого AppLimits.kt. +private const val MAX_PORT: Int = 65535 +private const val MIN_EMBEDDING_DIM: Int = 1 +private const val MAX_EMBEDDING_DIM: Int = 4096 + +/** + * Single source of truth для всей конфигурации :standalone-рантайма. + * + * Раньше env vars читались тремя независимыми проходами (AgentikConfig.fromEnv, + * LlmConfig.fromEnv, McpConfig.fromEnv) — плюс LlmConfig.resolveContextWindow + * перечитывал env повторно. Теперь все ~30 vars читаются и валидируются в одном + * fromEnv(env: (String) -> String?) заходе, дальше по модулю ходит только + * иммутабельный AppConfig с секциями. + * + * Потребители (pw.binom.agentik.standalone.Main) читают только этот тип — + * прямых System.getenv вне fromEnv быть не должно. + */ +@Serializable +data class AppConfig( + val agent: AgentSection, + val llm: LlmConfig, + val mcp: McpConfig, + val memory: MemorySection, + val embedding: EmbeddingSection, + val reflection: ReflectionSection, + val skillMining: SkillMiningSection, + val debug: DebugSection, +) { + /** HTTP-сервер, БД, skills, soul. */ + @Serializable + data class AgentSection( + val port: Int = DEFAULT_PORT, + val dbPath: String = DEFAULT_DB_PATH, + /** Папка со скилами (SKILL.md / *.yaml). `null` — скилы выключены. */ + val skillsDir: String? = null, + /** + * Путь к SOUL.md — файл с описанием персоны ассистента (markdown body). + * `null` — файл не читается, секция не добавляется. + */ + val soulPath: String? = null, + ) + + /** Долговременная память. */ + @Serializable + data class MemorySection( + /** + * Корневая директория памяти (Hermes-style §-файлы). `null` — память + * включается на дефолте ~/.agentik/memory. Спецзначение "off" — + * память выключена. + */ + val dir: String? = null, + /** Бэкенд долговременной памяти. */ + val backend: MemoryBackend = MemoryBackend.MD, + /** Порог compaction'а working memory. Default: 0.8 (80%). */ + val compressionThreshold: Double = DEFAULT_COMPRESSION_THRESHOLD, + /** Через сколько ходов запускать LLM-based memory review. `0` — выключает. */ + val reviewInterval: Int = DEFAULT_MEMORY_REVIEW_INTERVAL, + ) + + /** Эмбеддинги для vector-бэкенда памяти. */ + @Serializable + data class EmbeddingSection( + val model: String = DEFAULT_EMBEDDING_MODEL, + val dimension: Int = DEFAULT_EMBEDDING_DIMENSION, + /** HTTP — POST /v1/embeddings к OpenAI-совместимому API (default); + * SIGLIP — on-device SigLIP2 через ONNX Runtime, без сети. */ + val backend: EmbeddingBackend = EmbeddingBackend.HTTP, + /** Путь к ONNX-модели SigLIP2. Используется только при backend = SIGLIP. */ + val modelPath: String? = null, + /** Путь к sentencepiece-токенизатору. Используется только при backend = SIGLIP. */ + val tokenizerPath: String? = null, + ) + + /** Self-reflection: фоновый LLM-прогон, подмешивающий top-K рефлексий в system prompt. */ + @Serializable + data class ReflectionSection( + val interval: Int = DEFAULT_REFLECTION_INTERVAL, + val topK: Int = DEFAULT_REFLECTION_TOP_K, + ) + + /** Skill mining: фоновый LLM-прогон, находящий переиспользуемые скилы. */ + @Serializable + data class SkillMiningSection( + val interval: Int = DEFAULT_SKILL_MINING_INTERVAL, + val maxTurns: Int = DEFAULT_SKILL_MINING_MAX_TURNS, + ) + + /** Debug-эндпоинты (/debug/reflect, /debug/skill-mine, и т.д.). */ + @Serializable + data class DebugSection( + val endpoints: Boolean = false, + ) + + /** Бэкенд долговременной памяти. */ + @Serializable + enum class MemoryBackend { MD, VECTOR, OFF } + + /** Бэкенд эмбеддингов (для memory-backend=vector). */ + @Serializable + enum class EmbeddingBackend { HTTP, SIGLIP } + + companion object { + const val DEFAULT_PORT: Int = 8080 + const val DEFAULT_DB_PATH: String = "./agentik.db" + const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8 + const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small" + const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536 + const val DEFAULT_REFLECTION_INTERVAL: Int = 10 + const val DEFAULT_REFLECTION_TOP_K: Int = 3 + const val DEFAULT_SKILL_MINING_INTERVAL: Int = 15 + const val DEFAULT_SKILL_MINING_MAX_TURNS: Int = 30 + const val DEFAULT_MEMORY_REVIEW_INTERVAL: Int = 10 + + /** + * Читает ВСЮ конфигурацию :standalone одним проходом. + */ + fun fromEnv(env: (String) -> String? = System::getenv): AppConfig { + val llmBackend = parseLlmBackend(env("AGENTIK_LLM_BACKEND")) + val systemPrompt = env("AGENTIK_SYSTEM_PROMPT") + ?.takeIf { it.isNotBlank() } + ?: LlmConfig.DEFAULT_SYSTEM_PROMPT + + val llm = when (llmBackend) { + LlmBackend.OPENAI -> LlmConfig( + backend = llmBackend, + systemPrompt = systemPrompt, + openai = OpenAiConfig( + baseUrl = requireEnv(env, "OPENAI_BASE_URL"), + apiKey = requireEnv(env, "OPENAI_API_KEY"), + model = requireEnv(env, "OPENAI_MODEL"), + contextWindow = env("OPENAI_CONTEXT_WINDOW")?.toIntOrNull(), + ), + ) + LlmBackend.GOOGLE -> LlmConfig( + backend = llmBackend, + systemPrompt = systemPrompt, + google = GoogleConfig( + modelPath = requireEnv(env, "AGENTIK_GOOGLE_MODEL_PATH"), + cacheDir = env("AGENTIK_GOOGLE_CACHE_DIR")?.takeIf { it.isNotBlank() }, + threads = env("AGENTIK_GOOGLE_THREADS")?.toIntOrNull(), + contextWindow = env("AGENTIK_GOOGLE_CONTEXT_WINDOW")?.toIntOrNull(), + ), + ) + } + + return AppConfig( + agent = AgentSection( + port = env("AGENTIK_PORT")?.toIntOrNull() + ?.takeIf { it in 0..MAX_PORT } + ?: DEFAULT_PORT, + dbPath = env("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: DEFAULT_DB_PATH, + skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() }, + soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() }, + ), + llm = llm, + mcp = McpConfig.fromEnv(env), + memory = MemorySection( + dir = env("AGENTIK_MEMORY_DIR")?.takeIf { it.isNotBlank() }, + backend = env("AGENTIK_MEMORY_BACKEND")?.let { + runCatching { MemoryBackend.valueOf(it.uppercase()) }.getOrNull() + } ?: MemoryBackend.MD, + compressionThreshold = env("AGENTIK_COMPRESSION_THRESHOLD")?.toDoubleOrNull() + ?.coerceIn(0.1, 0.99) ?: DEFAULT_COMPRESSION_THRESHOLD, + reviewInterval = env("AGENTIK_MEMORY_REVIEW_INTERVAL")?.toIntOrNull() + ?.coerceIn(0, 1000) ?: DEFAULT_MEMORY_REVIEW_INTERVAL, + ), + embedding = EmbeddingSection( + model = env("AGENTIK_EMBEDDING_MODEL")?.takeIf { it.isNotBlank() } + ?: DEFAULT_EMBEDDING_MODEL, + dimension = env("AGENTIK_EMBEDDING_DIMENSION")?.toIntOrNull() + ?.coerceIn(MIN_EMBEDDING_DIM, MAX_EMBEDDING_DIM) + ?: DEFAULT_EMBEDDING_DIMENSION, + backend = env("AGENTIK_EMBEDDING_BACKEND")?.let { + runCatching { EmbeddingBackend.valueOf(it.uppercase()) }.getOrNull() + } ?: EmbeddingBackend.HTTP, + modelPath = env("AGENTIK_EMBEDDING_MODEL_PATH")?.takeIf { it.isNotBlank() }, + tokenizerPath = env("AGENTIK_EMBEDDING_TOKENIZER_PATH")?.takeIf { it.isNotBlank() }, + ), + reflection = ReflectionSection( + interval = env("AGENTIK_REFLECTION_INTERVAL")?.toIntOrNull() + ?.coerceIn(0, 1000) ?: DEFAULT_REFLECTION_INTERVAL, + topK = env("AGENTIK_REFLECTION_TOP_K")?.toIntOrNull() + ?.coerceIn(0, 20) ?: DEFAULT_REFLECTION_TOP_K, + ), + skillMining = SkillMiningSection( + interval = env("AGENTIK_SKILL_MINING_INTERVAL")?.toIntOrNull() + ?.coerceIn(0, 1000) ?: DEFAULT_SKILL_MINING_INTERVAL, + maxTurns = env("AGENTIK_SKILL_MINING_MAX_TURNS")?.toIntOrNull() + ?.coerceIn(1, 1000) ?: DEFAULT_SKILL_MINING_MAX_TURNS, + ), + debug = DebugSection( + endpoints = env("AGENTIK_DEBUG_ENDPOINTS")?.let { + it.equals("1", ignoreCase = true) || it.equals("true", ignoreCase = true) + } ?: false, + ), + ) + } + + private fun requireEnv(env: (String) -> String?, name: String): String = + env(name)?.takeIf { it.isNotBlank() } + ?: error("Required env var $name is not set or is blank") + + private fun parseLlmBackend(raw: String?): LlmBackend = when (raw?.takeIf { it.isNotBlank() }?.uppercase()) { + "OPENAI" -> LlmBackend.OPENAI + "GOOGLE" -> LlmBackend.GOOGLE + null -> LlmBackend.OPENAI + else -> error("Unknown AGENTIK_LLM_BACKEND: '$raw' (expected: openai, google)") + } + } +} diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt index a49a675..8a6ee4c 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt @@ -8,9 +8,20 @@ import pw.binom.litert.LiteLlm import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig import pw.binom.litert.openai.openAiLiteLlm +// NB: Этот файл хранит DTO-типы LLM (LlmConfig / OpenAiConfig / GoogleConfig / +// LlmBackend), но больше НЕ читает env vars сам — env-чтение переехало в +// AppConfig.fromEnv (single source of truth). Старые LlmConfig.fromEnv и +// LlmConfig.resolveContextWindow удалены: они перечитывали те же env vars, +// которые AppConfig уже распарсил, а env-default в resolveContextWindow ещё +// и ломал testability (резолвил против System.getenv вместо test-lambda). + /** - * Конфигурация LLM. Сериализуемая: наши собственные [OpenAiConfig]/[GoogleConfig] - * вместо внешних litert-типов, чтобы работал и env, и (позже) файл. + * Конфигурация LLM. Сериализуемая: наши собственные OpenAiConfig/GoogleConfig + * вместо внешних litert-типов, чтобы работал и AppConfig, и (позже) файл. + * + * Наполнение из env теперь живёт в + * [pw.binom.agentik.standalone.config.AppConfig.fromEnv] — этот класс + * остаётся чистым DTO. */ @Serializable data class LlmConfig( @@ -37,53 +48,21 @@ data class LlmConfig( } /** - * Размер контекстного окна в токенах для текущего бэкенда, или `null`, - * если не задан ни в env, ни в конфиге. Когда `null` — [ChatConversation] - * не считает лимит и compaction не запускается. + * Размер контекстного окна в токенах для активного бэкенда, или null, + * если не задан в AppConfig. Когда null — [ChatConversation] не + * считает лимит и compaction не запускается. * - * Резолвер env (вызывается один раз из `Main.kt`): `OPENAI_CONTEXT_WINDOW` - * для OpenAI-совместимых, `AGENTIK_GOOGLE_CONTEXT_WINDOW` для Google/LiteRT. - * Никакого автодетекта по имени модели — если лимит не задан, лучше не - * сжимать вообще, чем угадывать. + * Заменяет старый LlmConfig.resolveContextWindow(env) — env-чтение + * теперь живёт только в AppConfig.fromEnv, здесь мы просто + * прокидываем уже распарсенное значение активного бэкенда. */ - fun resolveContextWindow(env: (String) -> String? = System::getenv): Int? = when (backend) { - LlmBackend.OPENAI -> env("OPENAI_CONTEXT_WINDOW")?.toIntOrNull() - ?: openai?.contextWindow - LlmBackend.GOOGLE -> env("AGENTIK_GOOGLE_CONTEXT_WINDOW")?.toIntOrNull() - ?: google?.contextWindow + val contextWindow: Int? get() = when (backend) { + LlmBackend.OPENAI -> openai?.contextWindow + LlmBackend.GOOGLE -> google?.contextWindow } companion object { const val DEFAULT_SYSTEM_PROMPT: String = "Ты полезный ассистент. Отвечай кратко и по делу." - - fun fromEnv(env: (String) -> String? = System::getenv): LlmConfig { - val backend = LlmBackend.parse(env("AGENTIK_LLM_BACKEND")) - val systemPromptRaw = env("AGENTIK_SYSTEM_PROMPT") - val systemPrompt = if (systemPromptRaw.isNullOrBlank()) DEFAULT_SYSTEM_PROMPT else systemPromptRaw - return when (backend) { - LlmBackend.OPENAI -> { - val openai = OpenAiConfig( - baseUrl = requireEnv(env, "OPENAI_BASE_URL"), - apiKey = requireEnv(env, "OPENAI_API_KEY"), - model = requireEnv(env, "OPENAI_MODEL"), - contextWindow = env("OPENAI_CONTEXT_WINDOW")?.toIntOrNull(), - ) - LlmConfig(backend, systemPrompt, openai = openai) - } - LlmBackend.GOOGLE -> { - val google = GoogleConfig( - modelPath = requireEnv(env, "AGENTIK_GOOGLE_MODEL_PATH"), - cacheDir = env("AGENTIK_GOOGLE_CACHE_DIR"), - threads = env("AGENTIK_GOOGLE_THREADS")?.toInt(), - contextWindow = env("AGENTIK_GOOGLE_CONTEXT_WINDOW")?.toIntOrNull(), - ) - LlmConfig(backend, systemPrompt, google = google) - } - } - } - - private fun requireEnv(env: (String) -> String?, name: String): String = - env(name) ?: error("Required env var $name is not set") } } @@ -94,8 +73,8 @@ data class OpenAiConfig( val apiKey: String, val model: String, /** - * Лимит контекстного окна в токенах. `null` → берётся из env - * `OPENAI_CONTEXT_WINDOW`, иначе compaction не запускается. + * Лимит контекстного окна в токенах. null → compaction не запускается. + * Заполняется один раз в AppConfig.fromEnv из env OPENAI_CONTEXT_WINDOW. */ val contextWindow: Int? = null, ) { @@ -109,14 +88,6 @@ data class OpenAiConfig( enum class LlmBackend { OPENAI, GOOGLE; - - companion object { - fun parse(raw: String?): LlmBackend = when (raw?.lowercase()) { - null, "", "openai" -> OPENAI - "google", "litert", "litert-google" -> GOOGLE - else -> error("Unknown LLM backend '$raw', expected 'openai' or 'google'") - } - } } /** Настройки встроенного LiteRT-LM (Google) бэкенда. */ @@ -126,8 +97,8 @@ data class GoogleConfig( val cacheDir: String? = null, val threads: Int? = null, /** - * Лимит контекстного окна в токенах. `null` → берётся из env - * `AGENTIK_GOOGLE_CONTEXT_WINDOW`, иначе compaction не запускается. + * Лимит контекстного окна в токенах. null → compaction не запускается. + * Заполняется один раз в AppConfig.fromEnv из env AGENTIK_GOOGLE_CONTEXT_WINDOW. */ val contextWindow: Int? = null, ) { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index 8f6a114..2f55e22 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -37,6 +37,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant +import pw.binom.agentik.toolsets.NamedTool class ChatAgentTest { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt index d6d9020..79adcf0 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/CompactionTest.kt @@ -8,7 +8,6 @@ import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.NewMemoryNote import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.md.KeywordMdReviewer -import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.OpenAiConfig @@ -31,6 +30,8 @@ import kotlin.test.assertTrue import kotlin.test.assertFalse import kotlin.time.Instant import kotlinx.coroutines.flow.asSharedFlow +import pw.binom.agentik.llm.tools.ContextCompactor +import pw.binom.agentik.llm.tools.SummaryTurn /** * Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt index 5c30dd1..57ec8c8 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/MemoryWiringTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.withTimeout import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import kotlinx.io.files.SystemTemporaryDirectory +import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryNote import pw.binom.agentik.memory.MemoryPrefetcher @@ -30,6 +31,8 @@ import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.storage.sqlite.SqliteStores +import pw.binom.agentik.llm.tools.ContextCompactor +import pw.binom.agentik.llm.tools.SummaryTurn import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -68,6 +71,9 @@ class MemoryWiringTest { memoryStore: MemoryStore, prefetcher: MemoryPrefetcher, reviewer: MemoryReviewer, + contextWindow: Int? = null, + compressionThreshold: Double = 0.8, + contextCompactor: ContextCompactor = EchoCompactor, ): ChatAgent = ChatAgent( id = "agentik", storage = storage, @@ -80,6 +86,9 @@ class MemoryWiringTest { memoryStore = memoryStore, memoryPrefetcher = prefetcher, memoryReviewer = reviewer, + contextWindow = contextWindow, + compressionThreshold = compressionThreshold, + contextCompactor = contextCompactor, ) @Test @@ -248,36 +257,58 @@ class MemoryWiringTest { } @Test - fun `reviewer upserts suggested notes after a successful turn`() = runTest { + fun `reviewer fires on compaction (event-driven), not on every turn`() = runTest { + // После перехода на event-driven: review fires ТОЛЬКО в CompactionCoordinator.compactPreTurn() + // (через reviewer.reviewPreCompaction()) — не на каждый turn, не по interval'у. + // На простом turn без compaction review НЕ запускается. val store = openMdMemorySystem(root) fakeLlm.reply = "Sure, I'll remember that." - val reviewerReturned = CompletableDeferred() + val reviewerInvoked = CompletableDeferred() val reviewer = object : MemoryReviewer { - override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision { - val decision = MemoryReviewDecision( + override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = + error("review() не должен вызываться — только reviewPreCompaction() на compaction") + override suspend fun reviewPreCompaction(turns: List): MemoryReviewDecision { + reviewerInvoked.complete(Unit) + return MemoryReviewDecision( toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")), toDelete = emptyList(), ) - reviewerReturned.complete(Unit) - return decision } } - val agent = newAgent(store.store, StaticPrefetcher { _, _ -> emptyList() }, reviewer) + val agent = newAgent( + store.store, + StaticPrefetcher { _, _ -> emptyList() }, + reviewer, + contextWindow = 1024, // forceCompactNow требует contextWindow + ) val conv = agent.createConversation(temp = false) as ChatConversation - conv.send(listOf(Content.Text("please note: I prefer k8s"))) - // Дожидаемся, пока ревьюер отдаст решение, и ещё немного — чтобы - // scheduleReview успел сделать upsert в IO-диспетчере. + // Отправляем turn — ничего не должно триггериться, т.к. event-driven review + // fires только на compaction/closing. + conv.send(listOf(Content.Text("please note: I prefer k8s over docker swarm"))) + + // Ждём немного, проверяем что reviewer.review() НЕ был вызван withContext(Dispatchers.Default.limitedParallelism(1)) { - withTimeout(2_000) { reviewerReturned.await() } + delay(200) + } + assertTrue(store.store.list(category = MemoryCategory.PREFERENCE).isEmpty(), + "review НЕ должен был вызваться на простом turn без compaction") + + // Триггерим compaction вручную (через debug-API ConversationLoop.forceCompactNow). + // Это вызывает CompactionCoordinator.compactPreTurn → reviewer.reviewPreCompaction. + conv.forceCompactNow() + + // Дожидаемся reviewPreCompaction и upsert'а в IO-диспетчере. + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(2_000) { reviewerInvoked.await() } withTimeout(2_000) { while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20) } } val notes = store.store.list(category = MemoryCategory.PREFERENCE) - assertEquals(1, notes.size) + assertEquals(1, notes.size, "reviewPreCompaction должен сохранить заметку") assertEquals("prefers k8s", notes[0].content) assertEquals(MemorySource.AUTO_REVIEW, notes[0].source) agent.close() @@ -300,3 +331,9 @@ private class NoopReviewer : MemoryReviewer { override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList()) } + +/** Простой compactor для тестов: возвращает статичную строку. */ +private object EchoCompactor : ContextCompactor { + override suspend fun summarize(turns: List): String = + if (turns.isEmpty()) "" else "compacted-${turns.size}-turns" +} diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt index 8c934a2..001ccf4 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ReflectionParserTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import pw.binom.agentik.llm.tools.ReflectionParser class ReflectionParserTest { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt index 642d0f6..2ebcc38 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMinerTest.kt @@ -5,6 +5,7 @@ import pw.binom.agentik.memory.ConversationTurn import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import pw.binom.agentik.llm.tools.SkillMiner class SkillMinerTest { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt index 2dd33cf..8a20474 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/SkillMiningParserTest.kt @@ -3,6 +3,7 @@ package pw.binom.agentik.standalone.agent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import pw.binom.agentik.llm.tools.SkillMiningParser class SkillMiningParserTest { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt index cd0acdd..fb89e8a 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/LlmMemoryReviewerTest.kt @@ -14,6 +14,8 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Instant +import pw.binom.agentik.llm.tools.LlmMemoryReviewer +import pw.binom.agentik.llm.tools.ReviewPrompts class LlmMemoryReviewerTest { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt index 48df50a..e45c32a 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/memory/ReviewDecisionParserTest.kt @@ -5,6 +5,7 @@ import pw.binom.agentik.memory.MemoryReviewDecision import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import pw.binom.agentik.llm.tools.ReviewDecisionParser class ReviewDecisionParserTest { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt similarity index 57% rename from standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt rename to standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt index 2172fb0..9aa9742 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AppConfigTest.kt @@ -7,7 +7,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue -class AgentikConfigTest { +class AppConfigTest { private fun openAiEnv( extra: Map = emptyMap(), @@ -22,42 +22,42 @@ class AgentikConfigTest { @Test fun `defaults applied when port and db path absent`() { - val cfg = AgentikConfig.fromEnv(openAiEnv()) - assertEquals(AgentikConfig.DEFAULT_PORT, cfg.port) - assertEquals(AgentikConfig.DEFAULT_DB_PATH, cfg.dbPath) + val cfg = AppConfig.fromEnv(openAiEnv()) + assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port) + assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath) } @Test fun `reads port and db path from env`() { - val cfg = AgentikConfig.fromEnv( + val cfg = AppConfig.fromEnv( openAiEnv(mapOf("AGENTIK_PORT" to "9999", "AGENTIK_DB_PATH" to "/tmp/x.db")), ) - assertEquals(9999, cfg.port) - assertEquals("/tmp/x.db", cfg.dbPath) + assertEquals(9999, cfg.agent.port) + assertEquals("/tmp/x.db", cfg.agent.dbPath) } @Test fun `blank db path falls back to default`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " "))) - assertEquals(AgentikConfig.DEFAULT_DB_PATH, cfg.dbPath) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " "))) + assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath) } @Test fun `non-numeric port falls back to default`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port"))) - assertEquals(AgentikConfig.DEFAULT_PORT, cfg.port) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port"))) + assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port) } @Test - fun `delegates llm to LlmConfig fromEnv`() { - val cfg = AgentikConfig.fromEnv(openAiEnv()) + fun `delegates llm to AppConfig fromEnv`() { + val cfg = AppConfig.fromEnv(openAiEnv()) assertEquals(LlmBackend.OPENAI, cfg.llm.backend) assertEquals("gpt-4o-mini", cfg.llm.openai?.model) } @Test fun `google backend is selected via env`() { - val cfg = AgentikConfig.fromEnv { name -> + val cfg = AppConfig.fromEnv { name -> when (name) { "AGENTIK_LLM_BACKEND" -> "google" "AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm" @@ -71,13 +71,13 @@ class AgentikConfigTest { @Test fun `missing required llm env propagates`() { assertFailsWith { - AgentikConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null } + AppConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null } } } @Test fun `mcp empty when no config path`() { - val cfg = AgentikConfig.fromEnv(openAiEnv()) + val cfg = AppConfig.fromEnv(openAiEnv()) assertTrue(cfg.mcp.isEmpty) } @@ -94,7 +94,7 @@ class AgentikConfigTest { } """.trimIndent(), ) - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_MCP_CONFIG" to tmp.absolutePath))) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_MCP_CONFIG" to tmp.absolutePath))) assertEquals(1, cfg.mcp.servers.size) assertEquals("fetch", cfg.mcp.servers.first().name) } finally { @@ -104,41 +104,41 @@ class AgentikConfigTest { @Test fun `skills dir defaults to null`() { - assertEquals(null, AgentikConfig.fromEnv(openAiEnv()).skillsDir) + assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.skillsDir) } @Test fun `skills dir read from env`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills"))) - assertEquals("/skills", cfg.skillsDir) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills"))) + assertEquals("/skills", cfg.agent.skillsDir) } @Test fun `blank skills dir falls back to null`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " "))) - assertEquals(null, cfg.skillsDir) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " "))) + assertEquals(null, cfg.agent.skillsDir) } @Test fun `soul path defaults to null`() { - assertEquals(null, AgentikConfig.fromEnv(openAiEnv()).soulPath) + assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.soulPath) } @Test fun `soul path read from env`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md"))) - assertEquals("/etc/SOUL.md", cfg.soulPath) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md"))) + assertEquals("/etc/SOUL.md", cfg.agent.soulPath) } @Test fun `blank soul path falls back to null`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " "))) - assertEquals(null, cfg.soulPath) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " "))) + assertEquals(null, cfg.agent.soulPath) } @Test fun `serialization round-trips through json`() { - val original = AgentikConfig.fromEnv( + val original = AppConfig.fromEnv( openAiEnv( mapOf( "AGENTIK_PORT" to "7777", @@ -148,14 +148,14 @@ class AgentikConfigTest { ), ), ).copy( - mcp = pw.binom.agentik.standalone.mcp.McpConfig( + mcp = pw.binom.agentik.mcp.bridge.McpConfig( servers = listOf( - pw.binom.agentik.standalone.mcp.McpServerSpec.Stdio( + pw.binom.agentik.mcp.bridge.McpServerSpec.Stdio( name = "fetch", command = "uvx", args = listOf("mcp-server-fetch"), ), - pw.binom.agentik.standalone.mcp.McpServerSpec.Http( + pw.binom.agentik.mcp.bridge.McpServerSpec.Http( name = "remote", url = "https://example.com/mcp", headers = mapOf("Authorization" to "Bearer x"), @@ -165,35 +165,35 @@ class AgentikConfigTest { ) val json = Json { encodeDefaults = true } - val text = json.encodeToString(AgentikConfig.serializer(), original) - val restored = json.decodeFromString(AgentikConfig.serializer(), text) + val text = json.encodeToString(AppConfig.serializer(), original) + val restored = json.decodeFromString(AppConfig.serializer(), text) assertEquals(original, restored) } @Test fun `compressionThreshold defaults to 0_8 when env unset`() { - val cfg = AgentikConfig.fromEnv(openAiEnv()) - assertEquals(0.8, cfg.compressionThreshold) + val cfg = AppConfig.fromEnv(openAiEnv()) + assertEquals(0.8, cfg.memory.compressionThreshold) } @Test fun `compressionThreshold parsed from env`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6"))) - assertEquals(0.6, cfg.compressionThreshold) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6"))) + assertEquals(0.6, cfg.memory.compressionThreshold) } @Test fun `compressionThreshold clamped between min and max`() { - val tooLow = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01"))) - assertEquals(0.1, tooLow.compressionThreshold) - val tooHigh = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5"))) - assertEquals(0.99, tooHigh.compressionThreshold) + val tooLow = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01"))) + assertEquals(0.1, tooLow.memory.compressionThreshold) + val tooHigh = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5"))) + assertEquals(0.99, tooHigh.memory.compressionThreshold) } @Test fun `compressionThreshold garbage falls back to default`() { - val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен"))) - assertEquals(0.8, cfg.compressionThreshold) + val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен"))) + assertEquals(0.8, cfg.memory.compressionThreshold) } } diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt index de9a3df..1f6413d 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/llm/LlmConfigTest.kt @@ -1,14 +1,24 @@ package pw.binom.agentik.standalone.llm +import pw.binom.agentik.standalone.config.AppConfig import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith class LlmConfigTest { + private fun openAiEnv(extra: Map = emptyMap()): (String) -> String? = { name -> + when (name) { + "OPENAI_BASE_URL" -> "https://api.openai.com/v1" + "OPENAI_API_KEY" -> "sk-test" + "OPENAI_MODEL" -> "gpt-4o-mini" + else -> extra[name] + } + } + @Test fun `fromEnv — happy path`() { - val cfg = LlmConfig.fromEnv { name -> + val cfg = AppConfig.fromEnv { name -> when (name) { "OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_API_KEY" -> "sk-test" @@ -16,28 +26,21 @@ class LlmConfigTest { "AGENTIK_SYSTEM_PROMPT" -> "be brief" else -> null } - } + }.llm assertEquals("be brief", cfg.systemPrompt) assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai) } @Test fun `fromEnv — falls back to default system prompt`() { - val cfg = LlmConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - else -> null - } - } + val cfg = AppConfig.fromEnv(openAiEnv()).llm assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) } @Test fun `fromEnv — missing base url throws`() { assertFailsWith { - LlmConfig.fromEnv { name -> + AppConfig.fromEnv { name -> when (name) { "OPENAI_API_KEY" -> "sk-test" "OPENAI_MODEL" -> "gpt-4o-mini" @@ -50,7 +53,7 @@ class LlmConfigTest { @Test fun `fromEnv — missing api key throws`() { assertFailsWith { - LlmConfig.fromEnv { name -> + AppConfig.fromEnv { name -> when (name) { "OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_MODEL" -> "gpt-4o-mini" @@ -63,7 +66,7 @@ class LlmConfigTest { @Test fun `fromEnv — missing model throws`() { assertFailsWith { - LlmConfig.fromEnv { name -> + AppConfig.fromEnv { name -> when (name) { "OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_API_KEY" -> "sk-test" @@ -75,7 +78,7 @@ class LlmConfigTest { @Test fun `blank system prompt from env falls back to default`() { - val cfg = LlmConfig.fromEnv { name -> + val cfg = AppConfig.fromEnv { name -> when (name) { "OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_API_KEY" -> "sk-test" @@ -83,13 +86,13 @@ class LlmConfigTest { "AGENTIK_SYSTEM_PROMPT" -> " " else -> null } - } + }.llm assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) } @Test fun `fromEnv — OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() { - val cfg = LlmConfig.fromEnv { name -> + val cfg = AppConfig.fromEnv { name -> when (name) { "OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_API_KEY" -> "sk-test" @@ -97,13 +100,18 @@ class LlmConfigTest { "OPENAI_CONTEXT_WINDOW" -> "128000" else -> null } - } + }.llm assertEquals(128_000, cfg.openai?.contextWindow) } @Test - fun `resolveContextWindow — env wins over config`() { - val cfg = LlmConfig.fromEnv { name -> + fun `contextWindow — env parsed once and exposed via derived prop`() { + // Старый resolveContextWindow проверял "env wins over config" — но в новой + // модели env и config это одно и то же значение (env читается один раз в + // AppConfig.fromEnv и сохраняется в OpenAiConfig.contextWindow). Поэтому + // тут проверяем, что derived-prop LlmConfig.contextWindow правильно + // прокидывает значение из OpenAiConfig для активного бэкенда. + val cfg = AppConfig.fromEnv { name -> when (name) { "OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_API_KEY" -> "sk-test" @@ -111,27 +119,19 @@ class LlmConfigTest { "OPENAI_CONTEXT_WINDOW" -> "64000" else -> null } - } - // env задаёт 64000; resolveContextWindow возвращает именно его (openai.contextWindow = 64000 уже после fromEnv). - assertEquals(64_000, cfg.resolveContextWindow { it }) + }.llm + assertEquals(64_000, cfg.contextWindow) } @Test - fun `resolveContextWindow — returns null when nothing set`() { - val cfg = LlmConfig.fromEnv { name -> - when (name) { - "OPENAI_BASE_URL" -> "https://api.openai.com/v1" - "OPENAI_API_KEY" -> "sk-test" - "OPENAI_MODEL" -> "gpt-4o-mini" - else -> null - } - } - assertEquals(null, cfg.resolveContextWindow { null }) + fun `contextWindow — returns null when nothing set`() { + val cfg = AppConfig.fromEnv(openAiEnv()).llm + assertEquals(null, cfg.contextWindow) } @Test fun `fromEnv — OPENAI_CONTEXT_WINDOW garbage falls back to null`() { - val cfg = LlmConfig.fromEnv { name -> + val cfg = AppConfig.fromEnv { name -> when (name) { "OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_API_KEY" -> "sk-test" @@ -139,7 +139,7 @@ class LlmConfigTest { "OPENAI_CONTEXT_WINDOW" -> "не-число" else -> null } - } + }.llm assertEquals(null, cfg.openai?.contextWindow) } } diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt index dd1cdd2..b49eeff 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpConfigTest.kt @@ -3,6 +3,8 @@ package pw.binom.agentik.standalone.mcp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import pw.binom.agentik.mcp.bridge.McpConfig +import pw.binom.agentik.mcp.bridge.McpServerSpec class McpConfigTest { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt index 3514e25..8eadffd 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/mcp/McpRegistryTest.kt @@ -1,11 +1,13 @@ package pw.binom.agentik.standalone.mcp -import pw.binom.agentik.standalone.agent.NamedTool import pw.binom.litert.LiteTool import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertSame import kotlin.test.assertTrue +import pw.binom.agentik.toolsets.NamedTool +import pw.binom.agentik.mcp.bridge.McpConfig +import pw.binom.agentik.mcp.bridge.McpRegistry class McpRegistryTest {