config: единая AgentikConfig DTO + сериализация всей конфиг-цепочки

- 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.
This commit is contained in:
2026-09-13 21:49:12 +03:00
parent f4caab940e
commit 3fde5c28e3
7 changed files with 231 additions and 25 deletions
@@ -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=<path>.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=<path>.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()
@@ -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),
)
}
}
@@ -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,
@@ -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<McpServerSpec>,
val servers: List<McpServerSpec> = emptyList(),
) {
val isEmpty: Boolean get() = servers.isEmpty()
@@ -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
@@ -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<String, String> = 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<IllegalStateException> {
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)
}
}
@@ -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