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:
@@ -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
|
||||
|
||||
+138
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user