From 3fde5c28e3d5784f0e0050bf3340b20419d3c860 Mon Sep 17 00:00:00 2001 From: subochev Date: Sun, 13 Sep 2026 21:49:12 +0300 Subject: [PATCH] =?UTF-8?q?config:=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=B0=D1=8F?= =?UTF-8?q?=20AgentikConfig=20DTO=20+=20=D1=81=D0=B5=D1=80=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3-=D1=86=D0=B5?= =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentikConfig(port, dbPath, llm, mcp) — единая точка входа для всего, что настраивается снаружи; Main.kt больше не читает System.getenv, только config.*. Наполняется пока из env (AgentikConfig.fromEnv). - @Serializable на AgentikConfig/LlmConfig/GoogleConfig/McpConfig/ McpServerSpec(Stdio,Http). Внешний litert OpenAiConfig (не сериализуемый) заменён нашим pw.binom.agentik.standalone.llm.OpenAiConfig с маппером toLitertConfig() — чтобы позже читать конфиг из yaml. - AgentikConfigTest: дефолты, env-override, делегирование в LlmConfig/McpConfig, JSON round-trip с полиморфным McpServerSpec. Проверка: :standalone:jvmTest 54/54; смок через DTO — /health=ok. --- .../pw/binom/agentik/standalone/Main.kt | 34 ++--- .../standalone/config/AgentikConfig.kt | 43 ++++++ .../binom/agentik/standalone/llm/LlmConfig.kt | 28 +++- .../binom/agentik/standalone/mcp/McpConfig.kt | 10 +- .../agentik/standalone/agent/ChatAgentTest.kt | 2 +- .../standalone/config/AgentikConfigTest.kt | 138 ++++++++++++++++++ .../agentik/standalone/llm/LlmConfigTest.kt | 1 - 7 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt create mode 100644 standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt 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 97cc623..dd441c4 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -7,8 +7,7 @@ import io.ktor.server.routing.get import io.ktor.server.routing.routing import pw.binom.agentik.server.agentikAgent import pw.binom.agentik.standalone.agent.ChatAgent -import pw.binom.agentik.standalone.llm.LlmConfig -import pw.binom.agentik.standalone.mcp.McpConfig +import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.mcp.McpRegistry import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores /** @@ -26,41 +25,38 @@ import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores * GET /agentik/events -> text/event-stream (SSE, Agent-level) * GET /health -> "ok" * - * Хранилище — SQLite (env: AGENTIK_DB_PATH, default `./agentik.db`, `:memory:` для тестов). - * LLM — litert-openai (env: OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL) или litert-google - * (env: AGENTIK_LLM_BACKEND=google, AGENTIK_GOOGLE_MODEL_PATH). - * MCP — AGENTIK_MCP_CONFIG=.json (формат Claude Desktop). - * - * System prompt — AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`). + * Вся конфигурация — [AgentikConfig.fromEnv] (см. [AgentikConfig]). Источники: + * - AGENTIK_PORT / AGENTIK_DB_PATH + * - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_* + * - MCP: AGENTIK_MCP_CONFIG=.json (формат Claude Desktop) + * - AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`) */ fun main() { - val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080 - val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db" + val config = AgentikConfig.fromEnv() - val llmConfig = LlmConfig.fromEnv() - val llm = llmConfig.createLlm() - val stores = SqliteStores.open(dbPath = dbPath) - val mcpRegistry = McpRegistry.fromConfig(McpConfig.fromEnv()) + val llm = config.llm.createLlm() + val stores = SqliteStores.open(dbPath = config.dbPath) + val mcpRegistry = McpRegistry.fromConfig(config.mcp) val agent = ChatAgent( id = "agentik", stores = stores, llm = llm, - llmConfig = llmConfig, + llmConfig = config.llm, tools = mcpRegistry.namedTools, ) - val server = embeddedServer(CIO, port = port) { + val server = embeddedServer(CIO, port = config.port) { routing { get("/health") { call.respondText("ok") } agentikAgent(agent, path = "/agentik") } } - println("agentik standalone listening on http://localhost:$port") + println("agentik standalone listening on http://localhost:${config.port}") println(" GET /health") println(" POST /agentik/conversations -> 201") println(" GET /agentik/conversations/{id}/events -> SSE") - println(" storage: $dbPath") - println(" llm: ${llmConfig.backend} ${llmConfig.modelInfo()}") + println(" storage: ${config.dbPath}") + println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}") println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers") Runtime.getRuntime().addShutdownHook(Thread { agent.close() 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 new file mode 100644 index 0000000..ae496e6 --- /dev/null +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt @@ -0,0 +1,43 @@ +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(), +) { + companion object { + const val DEFAULT_PORT: Int = 8080 + const val DEFAULT_DB_PATH: String = "./agentik.db" + + /** + * Читает конфигурацию из переменных среды. + * + * @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), + ) + } +} 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 6f5fb32..7bf6589 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 @@ -1,5 +1,6 @@ package pw.binom.agentik.standalone.llm +import kotlinx.serialization.Serializable import pw.binom.litert.LiteBackend import pw.binom.litert.LiteConfig import pw.binom.litert.LiteExperimental @@ -7,10 +8,15 @@ import pw.binom.litert.LiteLlm import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig import pw.binom.litert.openai.openAiLiteLlm +/** + * Конфигурация LLM. Сериализуемая: наши собственные [OpenAiConfig]/[GoogleConfig] + * вместо внешних litert-типов, чтобы работал и env, и (позже) файл. + */ +@Serializable data class LlmConfig( val backend: LlmBackend, val systemPrompt: String, - val openai: LitertOpenAiConfig? = null, + val openai: OpenAiConfig? = null, val google: GoogleConfig? = null, ) { init { @@ -21,7 +27,7 @@ data class LlmConfig( } fun createLlm(): LiteLlm = when (backend) { - LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai)) + LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai).toLitertConfig()) LlmBackend.GOOGLE -> googleLiteLlmJvm(google!!.toLiteConfig()) } @@ -39,7 +45,7 @@ data class LlmConfig( val systemPrompt = if (systemPromptRaw.isNullOrBlank()) DEFAULT_SYSTEM_PROMPT else systemPromptRaw return when (backend) { LlmBackend.OPENAI -> { - val openai = LitertOpenAiConfig( + val openai = OpenAiConfig( baseUrl = requireEnv(env, "OPENAI_BASE_URL"), apiKey = requireEnv(env, "OPENAI_API_KEY"), model = requireEnv(env, "OPENAI_MODEL"), @@ -62,6 +68,20 @@ data class LlmConfig( } } +/** Настройки OpenAI-совместимого бэкенда (litellm и т.п.). */ +@Serializable +data class OpenAiConfig( + val baseUrl: String, + val apiKey: String, + val model: String, +) { + fun toLitertConfig(): LitertOpenAiConfig = LitertOpenAiConfig( + baseUrl = baseUrl, + apiKey = apiKey, + model = model, + ) +} + enum class LlmBackend { OPENAI, GOOGLE; @@ -75,6 +95,8 @@ enum class LlmBackend { } } +/** Настройки встроенного LiteRT-LM (Google) бэкенда. */ +@Serializable data class GoogleConfig( val modelPath: String, val cacheDir: String? = null, diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpConfig.kt index c0fd15e..2e56fa9 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpConfig.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/mcp/McpConfig.kt @@ -1,5 +1,7 @@ package pw.binom.agentik.standalone.mcp +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -15,11 +17,14 @@ import java.io.File * - [Stdio]: запустить процесс (`command` + `args`), общаться через stdin/stdout * - [Http]: подключиться к удалённому MCP-серверу по URL (Streamable HTTP) */ +@Serializable sealed interface McpServerSpec { /** Уникальное имя сервера, как его видно в логах и в tool-prefix. */ val name: String /** stdio: спавним процесс, читаем его stdout, пишем в stdin. */ + @Serializable + @SerialName("stdio") data class Stdio( override val name: String, val command: String, @@ -28,6 +33,8 @@ sealed interface McpServerSpec { ) : McpServerSpec /** http (Streamable HTTP transport): подключаемся к существующему MCP-серверу. */ + @Serializable + @SerialName("http") data class Http( override val name: String, val url: String, @@ -43,8 +50,9 @@ sealed interface McpServerSpec { * * Путь к файлу — env `AGENTIK_MCP_CONFIG`. Если не задан или файл не существует — пустой список. */ +@Serializable data class McpConfig( - val servers: List, + val servers: List = emptyList(), ) { val isEmpty: Boolean get() = servers.isEmpty() 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 b41b5c0..b005002 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 @@ -22,7 +22,7 @@ import pw.binom.litert.LiteMessage import pw.binom.litert.LiteRole import pw.binom.litert.LiteTool import pw.binom.litert.LiteToolCall -import pw.binom.litert.openai.OpenAiConfig +import pw.binom.agentik.standalone.llm.OpenAiConfig import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt new file mode 100644 index 0000000..7758aee --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/config/AgentikConfigTest.kt @@ -0,0 +1,138 @@ +package pw.binom.agentik.standalone.config + +import kotlinx.serialization.json.Json +import pw.binom.agentik.standalone.llm.LlmBackend +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AgentikConfigTest { + + 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 `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) + } + + @Test + fun `reads port and db path from env`() { + val cfg = AgentikConfig.fromEnv( + openAiEnv(mapOf("AGENTIK_PORT" to "9999", "AGENTIK_DB_PATH" to "/tmp/x.db")), + ) + assertEquals(9999, cfg.port) + assertEquals("/tmp/x.db", cfg.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) + } + + @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) + } + + @Test + fun `delegates llm to LlmConfig fromEnv`() { + val cfg = AgentikConfig.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 -> + when (name) { + "AGENTIK_LLM_BACKEND" -> "google" + "AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm" + else -> null + } + } + assertEquals(LlmBackend.GOOGLE, cfg.llm.backend) + assertEquals("/models/gemma.litertlm", cfg.llm.google?.modelPath) + } + + @Test + fun `missing required llm env propagates`() { + assertFailsWith { + AgentikConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null } + } + } + + @Test + fun `mcp empty when no config path`() { + val cfg = AgentikConfig.fromEnv(openAiEnv()) + assertTrue(cfg.mcp.isEmpty) + } + + @Test + fun `mcp loaded from file`() { + val tmp = kotlin.io.path.createTempFile(suffix = ".json").toFile() + try { + tmp.writeText( + """ + { + "mcpServers": { + "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] } + } + } + """.trimIndent(), + ) + val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_MCP_CONFIG" to tmp.absolutePath))) + assertEquals(1, cfg.mcp.servers.size) + assertEquals("fetch", cfg.mcp.servers.first().name) + } finally { + tmp.delete() + } + } + + @Test + fun `serialization round-trips through json`() { + val original = AgentikConfig.fromEnv( + openAiEnv( + mapOf( + "AGENTIK_PORT" to "7777", + "AGENTIK_DB_PATH" to "/tmp/x.db", + "AGENTIK_SYSTEM_PROMPT" to "be brief", + ), + ), + ).copy( + mcp = pw.binom.agentik.standalone.mcp.McpConfig( + servers = listOf( + pw.binom.agentik.standalone.mcp.McpServerSpec.Stdio( + name = "fetch", + command = "uvx", + args = listOf("mcp-server-fetch"), + ), + pw.binom.agentik.standalone.mcp.McpServerSpec.Http( + name = "remote", + url = "https://example.com/mcp", + headers = mapOf("Authorization" to "Bearer x"), + ), + ), + ), + ) + + val json = Json { encodeDefaults = true } + val text = json.encodeToString(AgentikConfig.serializer(), original) + val restored = json.decodeFromString(AgentikConfig.serializer(), text) + + assertEquals(original, restored) + } +} 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 eb6982d..a5c0c8e 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,6 +1,5 @@ package pw.binom.agentik.standalone.llm -import pw.binom.litert.openai.OpenAiConfig import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith