refactor(standalone): extract modules, event-driven background, AppConfig
ci / JVM build + tests (push) Failing after 2m5s

Standalone refactor — modularity + correctness improvements after
STANDALONE-REVIEW findings. Touches ~30 files. Build green, 178 tests pass.

(1) Module extractions — generic components out of :standalone:

  • :llm-tools (new KMP module, package pw.binom.agentik.llm.tools)
    - LlmReflector, SkillMiner, LlmMemoryReviewer, LiteLlmContextCompactor
    - Parsers: ReflectionParser, SkillMiningParser, ReviewDecisionParser
    - Prompts: ReflectionPrompts, SkillMiningPrompts, ReviewPrompts

  • :mcp-bridge (new JVM module, package pw.binom.agentik.mcp.bridge)
    - McpConfig, McpRegistry, McpLiteToolAdapter

  • NamedTool moved from :standalone to :agent-toolsets/commonMain
    - Generic (name + LiteTool) wrapper, used by both :mcp-bridge
      and :standalone's tool dispatcher

  :standalone loses ~1400 lines, depends on the two new modules.

(2) Background work → event-driven (no more interval-polling):

  • New :standalone/agent/BackgroundEvents.kt — internal event bus:
    - ToolCallEvent.Succeeded/Failed (emitted by ToolDispatcher after invoke)
    - CompactionEvent.Triggered (emitted by CompactionCoordinator pre-delete)
    - ConversationLifecycleEvent.Closing (emitted by ConversationLoop.close)

  • BackgroundScheduler rewritten as event subscriber:
    - On Closing: final reflection + skill mining (last-chance extraction)
    - On Compaction (turnsToDelete > 10): skill mining (debounced 60s)
    - On ToolFailure x2 in 60s window: reflection (debounced 5min)
    - Dropped: maybeScheduleReview/Reflection/SkillMining (interval-based)
    - Dropped config: memoryReviewInterval, reflectionInterval, skillMiningInterval

  • ToolDispatcher emits ToolCallEvent after each invoke.
  • CompactionCoordinator emits CompactionEvent before workingMemory.compact().
  • ConversationLoop.close() emits Closing BEFORE agentScope.cancel() so the
    subscription gets to run final reflection/mining.

  Net effect: typical 30-turn conversation runs ~38 LLM calls (was: 30 main +
  3 review + 3 reflection + 2 mining). With event-driven, review/mining only fire
  when their triggers actually make sense (compaction about to delete, or
  conversation closing).

(3) AppConfig single source of truth:

  • Replaces AgentikConfig + LlmConfig.fromEnv + McpConfig.fromEnv with one
    AppConfig.fromEnv() that reads all ~25 env vars in a single pass.
  • Sections: AgentSection, LlmSection, McpSection, MemorySection,
    EmbeddingSection, ReflectionSection, SkillMiningSection, DebugSection.
  • OPENAI_CONTEXT_WINDOW / AGENTIK_GOOGLE_CONTEXT_WINDOW no longer
    read twice (was a bug per STANDALONE-REVIEW E3).

(4) Other fixes inherited from earlier waves:

  • Hardening — size caps on user-input boundaries:
    MAX_MEMORY_CONTENT_LEN=32KB, MAX_SKILL_BODY_LEN=64KB,
    MAX_MCP_CONFIG_BYTES=1MB, MAX_A2A_REPLY_LEN=10MB, MAX_PORT=65535,
    blank-rejection in LlmConfig.requireEnv, URL/command validation.
  • Single scope — :standalone/agent/ConversationLoop has one
    agentScope (was: scope + backgroundScope).
  • liteConvRef race fix — capture-then-use pattern replaces !!-after-read;
    close() + runTurn.finally race on LiteConv JNI handled via
    AtomicReference.getAndSet.
  • SkillMiner.maxTurns / LlmReflector.maxTurns exposed as public (needed
    by BackgroundScheduler for prompt sizing).
  • Tests: MemoryWiringTest updated for new compaction-triggered review
    behavior; all parser/test imports updated for new packages.

Test results: 178/178 in :standalone, 36/36 in :agent-toolsets — all green.
This commit is contained in:
2026-09-18 20:43:54 +03:00
parent 25771a0c33
commit ac5d209fce
44 changed files with 833 additions and 518 deletions
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
@@ -8,5 +8,8 @@ import pw.binom.litert.LiteTool
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM) * Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`, * с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами. * чтобы избежать коллизий между разными MCP-серверами.
*
* Перенесён из `:standalone/agent/NamedTool.kt` — это generic data-класс,
* должен жить рядом с другими тулами в `:agent-toolsets`.
*/ */
data class NamedTool(val name: String, val tool: LiteTool) data class NamedTool(val name: String, val tool: LiteTool)
+37
View File
@@ -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)
}
}
}
@@ -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.LiteContentPart
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -27,7 +27,7 @@ import kotlin.time.Clock
*/ */
class LlmReflector( class LlmReflector(
private val llm: LiteLlm, private val llm: LiteLlm,
private val maxTurns: Int = 6, val maxTurns: Int = 6,
private val maxTokens: Int = 512, private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val clock: Clock = Clock.System, private val clock: Clock = Clock.System,
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
/** /**
* Минимальный парсер JSON-ответа от [LlmReflector]. * Минимальный парсер JSON-ответа от [LlmReflector].
@@ -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.memory.ConversationTurn
@@ -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.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision import pw.binom.agentik.memory.MemoryReviewDecision
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent.memory package pw.binom.agentik.llm.tools
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.llm.tools
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
@@ -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.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile import pw.binom.agentik.skills.SkillFile
+39
View File
@@ -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)
}
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.mcp package pw.binom.agentik.mcp.bridge
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@@ -1,4 +1,4 @@
package pw.binom.agentik.standalone.mcp package pw.binom.agentik.mcp.bridge
import mu.KotlinLogging import mu.KotlinLogging
@@ -30,9 +30,9 @@ import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Реестр подключённых MCP-серверов. * Реестр подключённых MCP-серверов.
+8
View File
@@ -28,6 +28,14 @@ dependencyResolutionManagement {
rootProject.name = "agentik" rootProject.name = "agentik"
include(":standalone") 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. Пока в нём пилим, потом вынесем. // Собственный протокол agentik. Пока в нём пилим, потом вынесем.
include(":proto") include(":proto")
// Парсер скилов (YAML-frontmatter + markdown body, opencode-style). // Парсер скилов (YAML-frontmatter + markdown body, opencode-style).
+6
View File
@@ -59,6 +59,12 @@ kotlin {
implementation(project(":storage-core")) implementation(project(":storage-core"))
implementation(project(":storage-sqlite")) implementation(project(":storage-sqlite"))
implementation(project(":agent-toolsets")) 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 // litert-google: встроенный LiteRT-LM движок, нужен только на runtime
runtimeOnly(libs.litert.google) runtimeOnly(libs.litert.google)
@@ -11,8 +11,8 @@ import kotlinx.serialization.json.put
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.proto.Agent import pw.binom.agentik.proto.Agent
import pw.binom.agentik.standalone.agent.ChatConversation import pw.binom.agentik.standalone.agent.ChatConversation
import pw.binom.agentik.standalone.agent.LlmReflector import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.standalone.agent.SkillMiner import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.standalone.agent.memory.Curator import pw.binom.agentik.standalone.agent.memory.Curator
import pw.binom.agentik.storage.StorageBundle import pw.binom.agentik.storage.StorageBundle
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
@@ -21,16 +21,17 @@ import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
import pw.binom.agentik.server.agentikAgent import pw.binom.agentik.server.agentikAgent
import pw.binom.agentik.skills.SkillCatalog import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.standalone.agent.ChatAgent import pw.binom.agentik.standalone.agent.ChatAgent
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor import pw.binom.agentik.llm.tools.LiteLlmContextCompactor
import pw.binom.agentik.standalone.agent.LlmReflector import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer import pw.binom.agentik.llm.tools.LlmMemoryReviewer
import pw.binom.agentik.standalone.config.AgentikConfig import pw.binom.agentik.standalone.config.AppConfig
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.ModelDownloader 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 pw.binom.agentik.storage.sqlite.SqliteStores
import java.io.File import java.io.File
import pw.binom.agentik.llm.tools.SkillMiner
/** /**
* standalone-контейнер agentik: * standalone-контейнер agentik:
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080) * - :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 /a2a/.well-known/agent-card.json -> AgentCard
* GET /health -> "ok" * GET /health -> "ok"
* *
* Вся конфигурация — [AgentikConfig.fromEnv] (см. [AgentikConfig]). Источники: * Вся конфигурация — [AppConfig.fromEnv] (см. [AppConfig]). Источники:
* - AGENTIK_PORT / AGENTIK_DB_PATH * - AGENTIK_PORT / AGENTIK_DB_PATH
* - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_* * - LLM: AGENTIK_LLM_BACKEND, OPENAI_* либо AGENTIK_GOOGLE_*
* - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop) * - MCP: AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop)
@@ -98,7 +99,7 @@ private fun printHelp() {
* Если файл по PATH уже есть и совпадает по размеру с HEAD — no-op (exit 0). * Если файл по PATH уже есть и совпадает по размеру с HEAD — no-op (exit 0).
*/ */
private fun runPullModel(args: List<String>) { private fun runPullModel(args: List<String>) {
val config = AgentikConfig.fromEnv() val config = AppConfig.fromEnv()
val google = config.llm.google val google = config.llm.google
?: error("pull-model: требуется AGENTIK_LLM_BACKEND=google (сейчас ${config.llm.backend})") ?: error("pull-model: требуется AGENTIK_LLM_BACKEND=google (сейчас ${config.llm.backend})")
@@ -148,7 +149,7 @@ private fun formatBytes(b: Long): String = when {
} }
private fun runServer() { private fun runServer() {
val config = AgentikConfig.fromEnv() val config = AppConfig.fromEnv()
// Перед созданием LLM: если backend=google и файл по AGENTIK_GOOGLE_MODEL_PATH // Перед созданием LLM: если backend=google и файл по AGENTIK_GOOGLE_MODEL_PATH
// отсутствует — качаем автоматически (только при AGENTIK_AUTO_DOWNLOAD_MODEL=1), // отсутствует — качаем автоматически (только при AGENTIK_AUTO_DOWNLOAD_MODEL=1),
@@ -195,14 +196,14 @@ private fun runServer() {
} }
val llm = config.llm.createLlm() 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) val mcpRegistry = McpRegistry.fromConfig(config.mcp)
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём // Хранилище скилов: если skillsDir задан, читаем каталог + создаём
// DiskSkillStore для self-improvement (`skill_save`/`skill_delete`). // DiskSkillStore для self-improvement (`skill_save`/`skill_delete`).
// Один и тот же файл-каталог используется и для чтения (read_skill), // Один и тот же файл-каталог используется и для чтения (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)) pw.binom.agentik.skills.DiskSkillStore(File(dir))
} }
val skills = skillStore?.catalog ?: SkillCatalog.EMPTY val skills = skillStore?.catalog ?: SkillCatalog.EMPTY
@@ -210,18 +211,18 @@ private fun runServer() {
// Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы, // Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы,
// которые модель забыла сохранить через `skill_save`. Работает только когда // которые модель забыла сохранить через `skill_save`. Работает только когда
// есть куда писать (skillStore) и интервал > 0. // есть куда писать (skillStore) и интервал > 0.
val skillMiner: pw.binom.agentik.standalone.agent.SkillMiner? = val skillMiner: pw.binom.agentik.llm.tools.SkillMiner? =
if (skillStore != null && config.skillMiningInterval > 0) { if (skillStore != null && config.skillMining.interval > 0) {
pw.binom.agentik.standalone.agent.SkillMiner( pw.binom.agentik.llm.tools.SkillMiner(
llm = llm, llm = llm,
maxTurns = config.skillMiningMaxTurns, maxTurns = config.skillMining.maxTurns,
) )
} else null } else null
// SOUL.md — файл персоны. Если задан — читается как plain text/markdown, // SOUL.md — файл персоны. Если задан — читается как plain text/markdown,
// вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0 // вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0
// (на старте агента это фатально: нечего показывать LLM). // (на старте агента это фатально: нечего показывать LLM).
val soulBody = config.soulPath?.let { path -> val soulBody = config.agent.soulPath?.let { path ->
val file = File(path) val file = File(path)
if (!file.exists() || !file.isFile) { if (!file.exists() || !file.isFile) {
log.warn { "SOUL file not found: $path" } log.warn { "SOUL file not found: $path" }
@@ -235,8 +236,8 @@ private fun runServer() {
// - MD (дефолт) — Hermes-style §-файлы в AGENTIK_MEMORY_DIR (~/.agentik/memory) // - MD (дефолт) — Hermes-style §-файлы в AGENTIK_MEMORY_DIR (~/.agentik/memory)
// - VECTOR — SQLite + JVector + LLM-эмбеддинги (тот же agentik.db для metadata) // - VECTOR — SQLite + JVector + LLM-эмбеддинги (тот же agentik.db для metadata)
// - OFF — память выключена (memoryDir="off" или memoryBackend="off") // - OFF — память выключена (memoryDir="off" или memoryBackend="off")
val rawMemory = config.memoryDir val rawMemory = config.memory.dir
val memorySystem: MemorySystem? = when (config.memoryBackend) { val memorySystem: MemorySystem? = when (config.memory.backend) {
MemoryBackend.OFF -> { MemoryBackend.OFF -> {
println(" memory: disabled") println(" memory: disabled")
null null
@@ -253,8 +254,8 @@ private fun runServer() {
} }
} }
MemoryBackend.VECTOR -> { MemoryBackend.VECTOR -> {
val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embeddingBackend) { val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embedding.backend) {
AgentikConfig.EmbeddingBackend.HTTP -> { AppConfig.EmbeddingBackend.HTTP -> {
val llm = config.llm val llm = config.llm
// Берём базовый URL + API key у активного LLM-бэкенда. // Берём базовый URL + API key у активного LLM-бэкенда.
// Поддерживается только OPENAI (LiteLLM proxy тоже работает, т.к. /v1/embeddings // Поддерживается только OPENAI (LiteLLM proxy тоже работает, т.к. /v1/embeddings
@@ -266,38 +267,38 @@ private fun runServer() {
HttpEmbeddingClient( HttpEmbeddingClient(
apiUrl = oa.baseUrl.trimEnd('/'), apiUrl = oa.baseUrl.trimEnd('/'),
apiKey = oa.apiKey, apiKey = oa.apiKey,
model = config.embeddingModel, model = config.embedding.model,
dimension = config.embeddingDimension, dimension = config.embedding.dimension,
) )
} }
AgentikConfig.EmbeddingBackend.SIGLIP -> { AppConfig.EmbeddingBackend.SIGLIP -> {
val modelPath = checkNotNull(config.embeddingModelPath) { val modelPath = checkNotNull(config.embedding.modelPath) {
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH" "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" "AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH"
} }
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath) SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath)
} }
} }
VectorMemorySystem.open( VectorMemorySystem.open(
dbPath = config.dbPath, dbPath = config.agent.dbPath,
embedding = embedding, embedding = embedding,
).also { ).also {
val backendLabel = when (config.embeddingBackend) { val backendLabel = when (config.embedding.backend) {
AgentikConfig.EmbeddingBackend.HTTP -> AppConfig.EmbeddingBackend.HTTP ->
"model=${config.embeddingModel}, dim=${config.embeddingDimension}" "model=${config.embedding.model}, dim=${config.embedding.dimension}"
AgentikConfig.EmbeddingBackend.SIGLIP -> AppConfig.EmbeddingBackend.SIGLIP ->
"model=siglip2-base (on-device), dim=${embedding.dimension}" "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). // Контекстное окно модели (для compaction'а working memory).
// Если null — compaction выключен. Резолвится один раз из LlmConfig/env. // Если 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 val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null
// Review-loop: всегда используем LlmMemoryReviewer поверх LiteLlm, если память включена. // Review-loop: всегда используем LlmMemoryReviewer поверх LiteLlm, если память включена.
@@ -312,11 +313,11 @@ private fun runServer() {
// Self-reflection: reflector работает только когда LLM доступен (нужен LiteLlm) // Self-reflection: reflector работает только когда LLM доступен (нужен LiteLlm)
// и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt. // и interval > 0. Загружаем top-K последних рефлексий из SQLite в system prompt.
val reflector: pw.binom.agentik.standalone.agent.LlmReflector? = val reflector: pw.binom.agentik.llm.tools.LlmReflector? =
if (config.reflectionInterval > 0) LlmReflector(llm = llm) else null if (config.reflection.interval > 0) LlmReflector(llm = llm) else null
val recentReflections: List<pw.binom.agentik.storage.Reflection> = val recentReflections: List<pw.binom.agentik.storage.Reflection> =
if (config.reflectionTopK > 0) kotlinx.coroutines.runBlocking { if (config.reflection.topK > 0) kotlinx.coroutines.runBlocking {
storage.reflectionStore.listRecent(config.reflectionTopK) storage.reflectionStore.listRecent(config.reflection.topK)
} else emptyList() } else emptyList()
val agent = ChatAgent( val agent = ChatAgent(
@@ -332,13 +333,11 @@ private fun runServer() {
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
soulBody = soulBody, soulBody = soulBody,
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = config.compressionThreshold, compressionThreshold = config.memory.compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
recentReflections = recentReflections, recentReflections = recentReflections,
reflector = reflector, reflector = reflector,
reflectionInterval = config.reflectionInterval,
skillMiner = skillMiner, skillMiner = skillMiner,
skillMiningInterval = config.skillMiningInterval,
) )
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а, // Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
@@ -351,12 +350,12 @@ private fun runServer() {
c c
} else null } else null
val server = embeddedServer(CIO, port = config.port) { val server = embeddedServer(CIO, port = config.agent.port) {
routing { routing {
get("/health") { call.respondText("ok") } get("/health") { call.respondText("ok") }
agentikAgent(agent, path = "/agentik") agentikAgent(agent, path = "/agentik")
a2aAgent(agentName = "agentik", handler = A2aBridge(agent), path = "/a2a") a2aAgent(agentName = "agentik", handler = A2aBridge(agent), path = "/a2a")
if (config.debugEndpoints) { if (config.debug.endpoints) {
debugRoutes( debugRoutes(
agent = agent, agent = agent,
storage = storage, 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(" GET /health")
println(" POST /agentik/conversations -> 201") println(" POST /agentik/conversations -> 201")
println(" GET /agentik/conversations/{id}/events -> SSE") println(" GET /agentik/conversations/{id}/events -> SSE")
println(" POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)") println(" POST /a2a/ -> A2A JSON-RPC (message/send, tasks/get, tasks/cancel)")
println(" GET /a2a/.well-known/agent-card.json -> AgentCard") 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(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers") println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
println(" skills: ${skills.size} loaded${config.skillsDir?.let { " from $it" } ?: ""}") println(" skills: ${skills.size} loaded${config.agent.skillsDir?.let { " from $it" } ?: ""}")
if (config.soulPath != null) println(" soul: ${config.soulPath} (${soulBody?.length ?: 0} chars)") if (config.agent.soulPath != null) println(" soul: ${config.agent.soulPath} (${soulBody?.length ?: 0} chars)")
println(" memory: ${if (memorySystem == null) "disabled" else "${config.memoryBackend.name.lowercase()}-backend"}") println(" memory: ${if (memorySystem == null) "disabled" else "${config.memory.backend.name.lowercase()}-backend"}")
if (contextWindow != null) { if (contextWindow != null) {
println(" compaction: enabled, threshold=${config.compressionThreshold}, window=$contextWindow tokens") println(" compaction: enabled, threshold=${config.memory.compressionThreshold}, window=$contextWindow tokens")
} else { } else {
println(" compaction: disabled (OPENAI_CONTEXT_WINDOW not set)") 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})") 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) { 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)") println(" debug endpoints: enabled (/debug/reflect, /debug/skill-mine, /debug/curate, /debug/compact, /debug/tokens)")
} }
// Token stats по существующим диалогам (агрегат на старте — каждая запись // Token stats по существующим диалогам (агрегат на старте — каждая запись
@@ -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<ToolCallEvent>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val toolCallEvents: SharedFlow<ToolCallEvent> get() = _toolCallEvents.asSharedFlow()
private val _compactionEvents = MutableSharedFlow<CompactionEvent>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val compactionEvents: SharedFlow<CompactionEvent> get() = _compactionEvents.asSharedFlow()
private val _lifecycleEvents = MutableSharedFlow<ConversationLifecycleEvent>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val lifecycleEvents: SharedFlow<ConversationLifecycleEvent> 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)
}
@@ -1,153 +1,196 @@
package pw.binom.agentik.standalone.agent 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.launch
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.storage.Content import pw.binom.agentik.storage.Content
import pw.binom.agentik.storage.MessageRecord
import pw.binom.agentik.storage.ReflectionStore import pw.binom.agentik.storage.ReflectionStore
import pw.binom.agentik.storage.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.WorkingMemoryStore 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( internal data class BackgroundConfig(
val memoryReviewer: MemoryReviewer?, val memoryReviewer: MemoryReviewer?,
val memoryStore: MemoryStore?, val memoryStore: MemoryStore?,
val memoryReviewInterval: Int = 0,
val reflectionStore: ReflectionStore?, val reflectionStore: ReflectionStore?,
val reflector: LlmReflector?, val reflector: LlmReflector?,
val reflectionInterval: Int,
val skillMiner: SkillMiner?, val skillMiner: SkillMiner?,
val skillMiningStore: SkillStore?, val skillMiningStore: SkillStore?,
val skillMiningInterval: Int,
) )
internal class BackgroundScheduler( internal class BackgroundScheduler(
private val state: ConversationState, private val state: ConversationState,
private val workingMemory: WorkingMemoryStore, private val workingMemory: WorkingMemoryStore,
private val config: BackgroundConfig, private val config: BackgroundConfig,
private val backgroundEvents: BackgroundEventBus,
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
fun maybeScheduleReview( private val lastSkillMiningAt = AtomicLong(0)
userRecord: MessageRecord.UserMessage, private val lastReflectionAt = AtomicLong(0)
assistantContent: List<Content>,
) { /** Recent tool failure timestamps (ms). Trimmed to [FAILURE_WINDOW_MS]. */
val reviewer = config.memoryReviewer ?: return private val toolFailures = mutableListOf<Long>()
val store = config.memoryStore ?: return private val toolFailuresLock = Any()
if (state.isTemporal) return
if (config.memoryReviewInterval > 0) { private var subscriptionJob: Job? = null
val userTurnCount = countUserTurnsBlocking()
if (userTurnCount % config.memoryReviewInterval != 0) return /** Запустить подписки. Вызывать один раз после конструктора. */
} fun start(scope: CoroutineScope) {
val userText = userRecord.content.filterIsInstance<Content.Text>() if (subscriptionJob?.isActive == true) return
.joinToString("\n") { it.body } subscriptionJob = scope.launch {
val assistantText = assistantContent.filterIsInstance<Content.Text>() // Merge all three event flows into one subscription scope. Each onEach
.joinToString("\n") { it.body } // returns Unit, so launchIn merges them as cold flows.
if (userText.isBlank() || assistantText.isBlank()) return merge(
val convId = state.id backgroundEvents.lifecycleEvents
state.agentScope.launch { .filterIsInstance<ConversationLifecycleEvent.Closing>()
try { .onEach { onClosing() },
val decision: MemoryReviewDecision = reviewer.review( backgroundEvents.compactionEvents
ReviewedTurn( .filterIsInstance<CompactionEvent.Triggered>()
userMessage = userText, .onEach { onCompaction(it) },
assistantMessage = assistantText, backgroundEvents.toolCallEvents
conversationId = convId, .filterIsInstance<ToolCallEvent.Failed>()
), .onEach { onToolFailure() },
) ).collect {}
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}" }
}
} }
} }
fun maybeScheduleReflection( private suspend fun onClosing() {
userRecord: MessageRecord.UserMessage, if (state.isTemporal) return
assistantContent: List<Content>, val convId = state.id
) { runFinalReflection(convId)
if (config.reflectionInterval <= 0) return runFinalSkillMining(convId)
}
private fun runFinalReflection(convId: String) {
val reflector = config.reflector ?: return val reflector = config.reflector ?: return
val store = config.reflectionStore ?: return val store = config.reflectionStore ?: return
if (state.isTemporal) return
val userText = userRecord.content.filterIsInstance<Content.Text>()
.joinToString("\n") { it.body }
val assistantText = assistantContent.filterIsInstance<Content.Text>()
.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 { state.agentScope.launch {
try { try {
val turns = listOf( val turns = recentTurns(reflector.maxTurns)
ConversationTurn( if (turns.isEmpty()) return@launch
userMessage = userText,
assistantMessage = assistantText,
)
)
val reflection = reflector.reflect(turns) ?: return@launch val reflection = reflector.reflect(turns) ?: return@launch
val stamped = reflection.copy(conversationId = convId) runCatching { store.insert(reflection.copy(conversationId = convId)) }
runCatching { store.insert(stamped) } .onFailure { log.warn(it) { "final reflection insert failed: ${it.message}" } }
.onFailure { log.warn(it) { "reflection insert failed: ${it.message}" } } log.info { "final reflection on close: conv=$convId score=${reflection.score}/5" }
log.info { "self-reflection score=${stamped.score}/5 conv=$convId spots=${stamped.weakSpots.size}" }
} catch (e: Throwable) { } catch (e: Throwable) {
log.warn(e) { "reflection failed for $convId: ${e.message}" } log.warn(e) { "final reflection failed for $convId: ${e.message}" }
} }
} }
} }
fun maybeScheduleSkillMining( private fun runFinalSkillMining(convId: String) {
userRecord: MessageRecord.UserMessage,
assistantContent: List<Content>,
) {
if (config.skillMiningInterval <= 0) return
val miner = config.skillMiner ?: return val miner = config.skillMiner ?: return
val store = config.skillMiningStore ?: return val store = config.skillMiningStore ?: return
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) { "final skill mining upsert '${s.name}' failed: ${it.message}" } }
}
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) { "final skill mining failed for $convId: ${e.message}" }
}
}
}
private fun onCompaction(event: CompactionEvent.Triggered) {
if (state.isTemporal) return if (state.isTemporal) return
val userTurnCount = countUserTurnsBlocking() if (event.turnsToDelete < MIN_COMPACTION_FOR_MINING) return
if (userTurnCount % config.skillMiningInterval != 0) 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}" }
}
}
}
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 val convId = state.id
state.agentScope.launch { state.agentScope.launch {
try { try {
val turns = recentTurnsFromWorkingMemory(miner.maxTurns) val turns = recentTurns(reflector.maxTurns)
if (turns.isEmpty()) return@launch if (turns.isEmpty()) return@launch
val existing = store.catalog.skills val reflection = reflector.reflect(turns) ?: return@launch
val mined = miner.mine(turns, existing) runCatching { store.insert(reflection.copy(conversationId = convId)) }
for (s in mined) { .onFailure { log.warn(it) { "reflection after failures insert failed: ${it.message}" } }
runCatching { store.upsert(s) } log.info { "reflection triggered by tool failures: conv=$convId score=${reflection.score}/5" }
.onFailure { log.warn(it) { "skill-mine upsert '${s.name}' failed: ${it.message}" } }
}
log.info { "skill-mine: conv=$convId turns=${turns.size} existing=${existing.size} mined=${mined.size}" }
} catch (e: Throwable) { } catch (e: Throwable) {
log.warn(e) { "skill-mine failed for $convId: ${e.message}" } log.warn(e) { "reflection after tool failures failed for $convId: ${e.message}" }
} }
} }
} }
private fun countUserTurnsBlocking(): Int = runBlocking { private suspend fun recentTurns(maxTurns: Int): List<ConversationTurn> {
var count = 0
for (row in workingMemory.list(state.id)) {
if (row.entry is WorkingMemoryEntry.User) count++
}
count
}
private suspend fun recentTurnsFromWorkingMemory(maxTurns: Int): List<ConversationTurn> {
val rows = workingMemory.list(state.id) val rows = workingMemory.list(state.id)
val pairs = mutableListOf<ConversationTurn>() val pairs = mutableListOf<ConversationTurn>()
var pendingUser: String? = null var pendingUser: String? = null
@@ -167,4 +210,17 @@ internal class BackgroundScheduler(
private fun List<Content>.text(): String = private fun List<Content>.text(): String =
filterIsInstance<Content.Text>().joinToString("\n") { it.body } filterIsInstance<Content.Text>().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
}
} }
@@ -28,6 +28,11 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.agentik.toolsets.ToolsetRegistry import pw.binom.agentik.toolsets.ToolsetRegistry
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import kotlin.time.Instant 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) и * Stateful [ProtoAgent] на базе SQLite (история + working memory) и
@@ -66,14 +71,6 @@ class ChatAgent(
private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null, private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null,
private val memoryPrefetcher: MemoryPrefetcher? = null, private val memoryPrefetcher: MemoryPrefetcher? = null,
private val memoryReviewer: MemoryReviewer? = 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-описание персоны. Вставляется в самое начало * Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
* системного промпта, поверх базы, навыков и memory-guidance. `null` — * системного промпта, поверх базы, навыков и memory-guidance. `null` —
@@ -103,19 +100,11 @@ class ChatAgent(
*/ */
private val reflector: LlmReflector? = null, private val reflector: LlmReflector? = null,
/** /**
* Через сколько пользовательских ходов запускать рефлексию. `0` = выключено. * Фоновый минер скилов: LLM-вызов, который запускается на compaction
*/ * (`turnsToDelete > 10`) или при closing conversation. `null` = mining выключен.
private val reflectionInterval: Int = 0,
/**
* Фоновый минер скилов: каждые N ходов LLM смотрит последние ходы и
* upsert-ит переиспользуемые скилы в [skillStore]. `null` = mining выключен.
* Сетка безопасности, если модель забыла вызвать `skill_save` сама. * Сетка безопасности, если модель забыла вызвать `skill_save` сама.
*/ */
private val skillMiner: SkillMiner? = null, private val skillMiner: SkillMiner? = null,
/**
* Через сколько пользовательских ходов запускать skill mining. `0` = выключено.
*/
private val skillMiningInterval: Int = 0,
/** /**
* Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает * Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает
* о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются, * о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются,
@@ -242,16 +231,13 @@ class ChatAgent(
memoryPrefetcher = memoryPrefetcher, memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
memoryStoreForReview = memoryStore, memoryStoreForReview = memoryStore,
memoryReviewInterval = memoryReviewInterval,
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflectionStore = storage.reflectionStore, reflectionStore = storage.reflectionStore,
reflector = reflector, reflector = reflector,
reflectionInterval = reflectionInterval,
skillMiner = skillMiner, skillMiner = skillMiner,
skillMiningStore = skillStore, skillMiningStore = skillStore,
skillMiningInterval = skillMiningInterval,
) )
runBlocking { runBlocking {
liveLock.withLock { live[conv.id] = conv } liveLock.withLock { live[conv.id] = conv }
@@ -294,16 +280,13 @@ class ChatAgent(
memoryPrefetcher = memoryPrefetcher, memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
memoryStoreForReview = memoryStore, memoryStoreForReview = memoryStore,
memoryReviewInterval = memoryReviewInterval,
contextWindow = contextWindow, contextWindow = contextWindow,
compressionThreshold = compressionThreshold, compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor, contextCompactor = contextCompactor,
reflectionStore = storage.reflectionStore, reflectionStore = storage.reflectionStore,
reflector = reflector, reflector = reflector,
reflectionInterval = reflectionInterval,
skillMiner = skillMiner, skillMiner = skillMiner,
skillMiningStore = skillStore, skillMiningStore = skillStore,
skillMiningInterval = skillMiningInterval,
) )
override fun close() { override fun close() {
@@ -16,6 +16,8 @@ import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole import pw.binom.litert.LiteRole
import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.SummaryTurn
internal class CompactionCoordinator( internal class CompactionCoordinator(
private val state: ConversationState, private val state: ConversationState,
@@ -27,6 +29,7 @@ internal class CompactionCoordinator(
private val workingMemory: WorkingMemoryStore, private val workingMemory: WorkingMemoryStore,
private val liteLlm: LiteLlm, private val liteLlm: LiteLlm,
private val systemPrompt: String, private val systemPrompt: String,
private val backgroundEvents: BackgroundEventBus,
) { ) {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -63,6 +66,12 @@ internal class CompactionCoordinator(
} }
if (toCompact.isEmpty()) return false 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 -> val turns = toCompact.mapNotNull { row ->
when (val e = row.entry) { when (val e = row.entry) {
is WorkingMemoryEntry.User -> SummaryTurn( is WorkingMemoryEntry.User -> SummaryTurn(
@@ -225,6 +234,8 @@ internal class CompactionCoordinator(
companion object { companion object {
private const val KEEP_RECENT_TURNS = 4 private const val KEEP_RECENT_TURNS = 4
private const val MAX_SEEDED_MESSAGES = 50 private const val MAX_SEEDED_MESSAGES = 50
/** Минимум удаляемых ходов чтобы BackgroundScheduler trigger'ил skill mining. */
private const val MIN_COMPACTION_FOR_MINING = 10
} }
} }
@@ -48,6 +48,10 @@ import pw.binom.litert.LiteTool
import pw.binom.litert.LiteToolCall import pw.binom.litert.LiteToolCall
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Instant 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( class ConversationLoop(
record: ConversationRecord, record: ConversationRecord,
@@ -59,16 +63,13 @@ class ConversationLoop(
private val memoryPrefetcher: MemoryPrefetcher? = null, private val memoryPrefetcher: MemoryPrefetcher? = null,
private val memoryReviewer: MemoryReviewer? = null, private val memoryReviewer: MemoryReviewer? = null,
private val memoryStoreForReview: MemoryStore? = null, private val memoryStoreForReview: MemoryStore? = null,
private val memoryReviewInterval: Int = 0,
private val contextWindow: Int? = null, private val contextWindow: Int? = null,
private val compressionThreshold: Double = 0.8, private val compressionThreshold: Double = 0.8,
private val contextCompactor: ContextCompactor? = null, private val contextCompactor: ContextCompactor? = null,
private val reflectionStore: ReflectionStore? = null, private val reflectionStore: ReflectionStore? = null,
private val reflector: LlmReflector? = null, private val reflector: LlmReflector? = null,
private val reflectionInterval: Int = 0,
private val skillMiner: SkillMiner? = null, private val skillMiner: SkillMiner? = null,
private val skillMiningStore: SkillStore? = null, private val skillMiningStore: SkillStore? = null,
private val skillMiningInterval: Int = 0,
) : ProtoConversation, AutoCloseable { ) : ProtoConversation, AutoCloseable {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
@@ -85,6 +86,9 @@ class ConversationLoop(
private val events = ConversationEvents() 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 conversationStore: ConversationStore get() = storage.conversationStore
private val messageStore: MessageStore get() = storage.messageStore private val messageStore: MessageStore get() = storage.messageStore
private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore
@@ -103,12 +107,14 @@ class ConversationLoop(
workingMemory = workingMemory, workingMemory = workingMemory,
liteLlm = llm, liteLlm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
backgroundEvents = backgroundEvents,
) )
private val toolDispatcher = ToolDispatcher( private val toolDispatcher = ToolDispatcher(
state = state, state = state,
messageStore = messageStore, messageStore = messageStore,
events = events, events = events,
backgroundEvents = backgroundEvents,
toolsByName = toolsByName, toolsByName = toolsByName,
toolsetDispatch = toolsetDispatch, toolsetDispatch = toolsetDispatch,
newId = ::newId, newId = ::newId,
@@ -122,15 +128,13 @@ class ConversationLoop(
config = BackgroundConfig( config = BackgroundConfig(
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
memoryStore = memoryStoreForReview, memoryStore = memoryStoreForReview,
memoryReviewInterval = memoryReviewInterval,
reflectionStore = reflectionStore, reflectionStore = reflectionStore,
reflector = reflector, reflector = reflector,
reflectionInterval = reflectionInterval,
skillMiner = skillMiner, skillMiner = skillMiner,
skillMiningStore = skillMiningStore, skillMiningStore = skillMiningStore,
skillMiningInterval = skillMiningInterval,
), ),
) backgroundEvents = backgroundEvents,
).also { it.start(agentScope) }
override val id: String get() = state.id override val id: String get() = state.id
override val isSupportImageInput: Boolean get() = false override val isSupportImageInput: Boolean get() = false
@@ -210,6 +214,10 @@ class ConversationLoop(
override fun close() { override fun close() {
if (state.isClosed) return if (state.isClosed) return
state.markClosed() 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() } } state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
runCatching { runBlocking { activeTurn?.cancelAndJoin() } } runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
agentScope.cancel() agentScope.cancel()
@@ -420,9 +428,9 @@ class ConversationLoop(
state.record = state.record.copy(updatedAt = assistantAt) state.record = state.record.copy(updatedAt = assistantAt)
conversationStore.touch(id, assistantAt) conversationStore.touch(id, assistantAt)
backgroundScheduler.maybeScheduleReview(userRecord, assistantContent) // BackgroundScheduler is event-driven — подписан на BackgroundEventBus
backgroundScheduler.maybeScheduleReflection(userRecord, assistantContent) // (compaction/lifecycle/tool-failure events). Никаких interval-based
backgroundScheduler.maybeScheduleSkillMining(userRecord, assistantContent) // вызовов сюда больше не идёт. См. BackgroundScheduler.kt.
} }
} }
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import pw.binom.agentik.storage.ConversationRecord import pw.binom.agentik.storage.ConversationRecord
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import pw.binom.agentik.toolsets.NamedTool
internal class ConversationState( internal class ConversationState(
initialRecord: ConversationRecord, initialRecord: ConversationRecord,
@@ -1,6 +1,7 @@
package pw.binom.agentik.standalone.agent package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillStore import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style). * Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style).
@@ -12,11 +12,13 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.litert.LiteToolCall import pw.binom.litert.LiteToolCall
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
internal class ToolDispatcher( internal class ToolDispatcher(
private val state: ConversationState, private val state: ConversationState,
private val messageStore: MessageStore, private val messageStore: MessageStore,
private val events: ConversationEvents, private val events: ConversationEvents,
private val backgroundEvents: BackgroundEventBus,
private val toolsByName: MutableMap<String, NamedTool>, private val toolsByName: MutableMap<String, NamedTool>,
private val toolsetDispatch: ToolsetDispatchPolicy?, private val toolsetDispatch: ToolsetDispatchPolicy?,
private val newId: (String) -> String, private val newId: (String) -> String,
@@ -39,6 +41,7 @@ internal class ToolDispatcher(
val resultId = newId("tr") val resultId = newId("tr")
val argsJson = encodeArgsJson(call.arguments) val argsJson = encodeArgsJson(call.arguments)
val nowTs = now() val nowTs = now()
val startMs = System.currentTimeMillis()
events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson)) events.tryEmit(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson))
@@ -74,6 +77,7 @@ internal class ToolDispatcher(
} }
_currentToolJob = toolDeferred _currentToolJob = toolDeferred
var failureError: String? = null
val resultText: String = try { val resultText: String = try {
toolDeferred.await() toolDeferred.await()
} catch (e: CancellationException) { } catch (e: CancellationException) {
@@ -82,7 +86,8 @@ internal class ToolDispatcher(
"[cancelled by user]" "[cancelled by user]"
} catch (e: Throwable) { } catch (e: Throwable) {
log.warn(e) { "tool '${call.name}' threw: ${e.message}" } 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 { } finally {
_currentToolJob = null _currentToolJob = null
} }
@@ -90,6 +95,15 @@ internal class ToolDispatcher(
val resultAt = now() val resultAt = now()
events.tryEmit(ProtoEvent.ToolResult(date = resultAt, id = resultId, result = resultText)) 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) { if (!state.isTemporal) {
messageStore.append( messageStore.append(
MessageRecord.ToolResult( MessageRecord.ToolResult(
@@ -22,6 +22,7 @@ import pw.binom.agentik.memory.NewMemoryNote
import java.util.UUID import java.util.UUID
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import pw.binom.agentik.toolsets.NamedTool
private val MemoryToolsJson = Json { ignoreUnknownKeys = true; isLenient = true } private val MemoryToolsJson = Json { ignoreUnknownKeys = true; isLenient = true }
private val MemoryResponseJson = Json { encodeDefaults = true } private val MemoryResponseJson = Json { encodeDefaults = true }
@@ -2,8 +2,8 @@ package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.MemoryStore import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import pw.binom.agentik.toolsets.NamedTool
/** /**
* Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete) * Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete)
@@ -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,
)
}
}
@@ -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)")
}
}
}
@@ -8,9 +8,20 @@ import pw.binom.litert.LiteLlm
import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig
import pw.binom.litert.openai.openAiLiteLlm 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] * Конфигурация LLM. Сериализуемая: наши собственные OpenAiConfig/GoogleConfig
* вместо внешних litert-типов, чтобы работал и env, и (позже) файл. * вместо внешних litert-типов, чтобы работал и AppConfig, и (позже) файл.
*
* Наполнение из env теперь живёт в
* [pw.binom.agentik.standalone.config.AppConfig.fromEnv] — этот класс
* остаётся чистым DTO.
*/ */
@Serializable @Serializable
data class LlmConfig( data class LlmConfig(
@@ -37,53 +48,21 @@ data class LlmConfig(
} }
/** /**
* Размер контекстного окна в токенах для текущего бэкенда, или `null`, * Размер контекстного окна в токенах для активного бэкенда, или null,
* если не задан ни в env, ни в конфиге. Когда `null` — [ChatConversation] * если не задан в AppConfig. Когда null — [ChatConversation] не
* не считает лимит и compaction не запускается. * считает лимит и compaction не запускается.
* *
* Резолвер env (вызывается один раз из `Main.kt`): `OPENAI_CONTEXT_WINDOW` * Заменяет старый LlmConfig.resolveContextWindow(env) — env-чтение
* для OpenAI-совместимых, `AGENTIK_GOOGLE_CONTEXT_WINDOW` для Google/LiteRT. * теперь живёт только в AppConfig.fromEnv, здесь мы просто
* Никакого автодетекта по имени модели — если лимит не задан, лучше не * прокидываем уже распарсенное значение активного бэкенда.
* сжимать вообще, чем угадывать.
*/ */
fun resolveContextWindow(env: (String) -> String? = System::getenv): Int? = when (backend) { val contextWindow: Int? get() = when (backend) {
LlmBackend.OPENAI -> env("OPENAI_CONTEXT_WINDOW")?.toIntOrNull() LlmBackend.OPENAI -> openai?.contextWindow
?: openai?.contextWindow LlmBackend.GOOGLE -> google?.contextWindow
LlmBackend.GOOGLE -> env("AGENTIK_GOOGLE_CONTEXT_WINDOW")?.toIntOrNull()
?: google?.contextWindow
} }
companion object { companion object {
const val DEFAULT_SYSTEM_PROMPT: String = "Ты полезный ассистент. Отвечай кратко и по делу." 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 apiKey: String,
val model: String, val model: String,
/** /**
* Лимит контекстного окна в токенах. `null` → берётся из env * Лимит контекстного окна в токенах. null → compaction не запускается.
* `OPENAI_CONTEXT_WINDOW`, иначе compaction не запускается. * Заполняется один раз в AppConfig.fromEnv из env OPENAI_CONTEXT_WINDOW.
*/ */
val contextWindow: Int? = null, val contextWindow: Int? = null,
) { ) {
@@ -109,14 +88,6 @@ data class OpenAiConfig(
enum class LlmBackend { enum class LlmBackend {
OPENAI, OPENAI,
GOOGLE; 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) бэкенда. */ /** Настройки встроенного LiteRT-LM (Google) бэкенда. */
@@ -126,8 +97,8 @@ data class GoogleConfig(
val cacheDir: String? = null, val cacheDir: String? = null,
val threads: Int? = null, val threads: Int? = null,
/** /**
* Лимит контекстного окна в токенах. `null` → берётся из env * Лимит контекстного окна в токенах. null → compaction не запускается.
* `AGENTIK_GOOGLE_CONTEXT_WINDOW`, иначе compaction не запускается. * Заполняется один раз в AppConfig.fromEnv из env AGENTIK_GOOGLE_CONTEXT_WINDOW.
*/ */
val contextWindow: Int? = null, val contextWindow: Int? = null,
) { ) {
@@ -37,6 +37,7 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
class ChatAgentTest { class ChatAgentTest {
@@ -8,7 +8,6 @@ import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.NewMemoryNote import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.memory.md.KeywordMdReviewer 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.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.standalone.llm.OpenAiConfig
@@ -31,6 +30,8 @@ import kotlin.test.assertTrue
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.SummaryTurn
/** /**
* Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а * Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а
@@ -11,6 +11,7 @@ import kotlinx.coroutines.withTimeout
import kotlinx.io.files.Path import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem import kotlinx.io.files.SystemFileSystem
import kotlinx.io.files.SystemTemporaryDirectory import kotlinx.io.files.SystemTemporaryDirectory
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryCategory import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryPrefetcher 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.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.storage.sqlite.SqliteStores 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.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
import kotlin.test.Test import kotlin.test.Test
@@ -68,6 +71,9 @@ class MemoryWiringTest {
memoryStore: MemoryStore, memoryStore: MemoryStore,
prefetcher: MemoryPrefetcher, prefetcher: MemoryPrefetcher,
reviewer: MemoryReviewer, reviewer: MemoryReviewer,
contextWindow: Int? = null,
compressionThreshold: Double = 0.8,
contextCompactor: ContextCompactor = EchoCompactor,
): ChatAgent = ChatAgent( ): ChatAgent = ChatAgent(
id = "agentik", id = "agentik",
storage = storage, storage = storage,
@@ -80,6 +86,9 @@ class MemoryWiringTest {
memoryStore = memoryStore, memoryStore = memoryStore,
memoryPrefetcher = prefetcher, memoryPrefetcher = prefetcher,
memoryReviewer = reviewer, memoryReviewer = reviewer,
contextWindow = contextWindow,
compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor,
) )
@Test @Test
@@ -248,36 +257,58 @@ class MemoryWiringTest {
} }
@Test @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) val store = openMdMemorySystem(root)
fakeLlm.reply = "Sure, I'll remember that." fakeLlm.reply = "Sure, I'll remember that."
val reviewerReturned = CompletableDeferred<Unit>() val reviewerInvoked = CompletableDeferred<Unit>()
val reviewer = object : MemoryReviewer { val reviewer = object : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision { override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
val decision = MemoryReviewDecision( error("review() не должен вызываться — только reviewPreCompaction() на compaction")
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
reviewerInvoked.complete(Unit)
return MemoryReviewDecision(
toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")), toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")),
toDelete = emptyList(), 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 val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(Content.Text("please note: I prefer k8s")))
// Дожидаемся, пока ревьюер отдаст решение, и ещё немного — чтобы // Отправляем turn — ничего не должно триггериться, т.к. event-driven review
// scheduleReview успел сделать upsert в IO-диспетчере. // fires только на compaction/closing.
conv.send(listOf(Content.Text("please note: I prefer k8s over docker swarm")))
// Ждём немного, проверяем что reviewer.review() НЕ был вызван
withContext(Dispatchers.Default.limitedParallelism(1)) { 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) { withTimeout(2_000) {
while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20) while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20)
} }
} }
val notes = store.store.list(category = MemoryCategory.PREFERENCE) val notes = store.store.list(category = MemoryCategory.PREFERENCE)
assertEquals(1, notes.size) assertEquals(1, notes.size, "reviewPreCompaction должен сохранить заметку")
assertEquals("prefers k8s", notes[0].content) assertEquals("prefers k8s", notes[0].content)
assertEquals(MemorySource.AUTO_REVIEW, notes[0].source) assertEquals(MemorySource.AUTO_REVIEW, notes[0].source)
agent.close() agent.close()
@@ -300,3 +331,9 @@ private class NoopReviewer : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList()) MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList())
} }
/** Простой compactor для тестов: возвращает статичную строку. */
private object EchoCompactor : ContextCompactor {
override suspend fun summarize(turns: List<SummaryTurn>): String =
if (turns.isEmpty()) "" else "compacted-${turns.size}-turns"
}
@@ -4,6 +4,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import pw.binom.agentik.llm.tools.ReflectionParser
class ReflectionParserTest { class ReflectionParserTest {
@@ -5,6 +5,7 @@ import pw.binom.agentik.memory.ConversationTurn
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiner
class SkillMinerTest { class SkillMinerTest {
@@ -3,6 +3,7 @@ package pw.binom.agentik.standalone.agent
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiningParser
class SkillMiningParserTest { class SkillMiningParserTest {
@@ -14,6 +14,8 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Instant import kotlin.time.Instant
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
import pw.binom.agentik.llm.tools.ReviewPrompts
class LlmMemoryReviewerTest { class LlmMemoryReviewerTest {
@@ -5,6 +5,7 @@ import pw.binom.agentik.memory.MemoryReviewDecision
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.ReviewDecisionParser
class ReviewDecisionParserTest { class ReviewDecisionParserTest {
@@ -7,7 +7,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertTrue import kotlin.test.assertTrue
class AgentikConfigTest { class AppConfigTest {
private fun openAiEnv( private fun openAiEnv(
extra: Map<String, String> = emptyMap(), extra: Map<String, String> = emptyMap(),
@@ -22,42 +22,42 @@ class AgentikConfigTest {
@Test @Test
fun `defaults applied when port and db path absent`() { fun `defaults applied when port and db path absent`() {
val cfg = AgentikConfig.fromEnv(openAiEnv()) val cfg = AppConfig.fromEnv(openAiEnv())
assertEquals(AgentikConfig.DEFAULT_PORT, cfg.port) assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port)
assertEquals(AgentikConfig.DEFAULT_DB_PATH, cfg.dbPath) assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath)
} }
@Test @Test
fun `reads port and db path from env`() { 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")), openAiEnv(mapOf("AGENTIK_PORT" to "9999", "AGENTIK_DB_PATH" to "/tmp/x.db")),
) )
assertEquals(9999, cfg.port) assertEquals(9999, cfg.agent.port)
assertEquals("/tmp/x.db", cfg.dbPath) assertEquals("/tmp/x.db", cfg.agent.dbPath)
} }
@Test @Test
fun `blank db path falls back to default`() { fun `blank db path falls back to default`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " "))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " ")))
assertEquals(AgentikConfig.DEFAULT_DB_PATH, cfg.dbPath) assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath)
} }
@Test @Test
fun `non-numeric port falls back to default`() { fun `non-numeric port falls back to default`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port"))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port")))
assertEquals(AgentikConfig.DEFAULT_PORT, cfg.port) assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port)
} }
@Test @Test
fun `delegates llm to LlmConfig fromEnv`() { fun `delegates llm to AppConfig fromEnv`() {
val cfg = AgentikConfig.fromEnv(openAiEnv()) val cfg = AppConfig.fromEnv(openAiEnv())
assertEquals(LlmBackend.OPENAI, cfg.llm.backend) assertEquals(LlmBackend.OPENAI, cfg.llm.backend)
assertEquals("gpt-4o-mini", cfg.llm.openai?.model) assertEquals("gpt-4o-mini", cfg.llm.openai?.model)
} }
@Test @Test
fun `google backend is selected via env`() { fun `google backend is selected via env`() {
val cfg = AgentikConfig.fromEnv { name -> val cfg = AppConfig.fromEnv { name ->
when (name) { when (name) {
"AGENTIK_LLM_BACKEND" -> "google" "AGENTIK_LLM_BACKEND" -> "google"
"AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm" "AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm"
@@ -71,13 +71,13 @@ class AgentikConfigTest {
@Test @Test
fun `missing required llm env propagates`() { fun `missing required llm env propagates`() {
assertFailsWith<IllegalStateException> { assertFailsWith<IllegalStateException> {
AgentikConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null } AppConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null }
} }
} }
@Test @Test
fun `mcp empty when no config path`() { fun `mcp empty when no config path`() {
val cfg = AgentikConfig.fromEnv(openAiEnv()) val cfg = AppConfig.fromEnv(openAiEnv())
assertTrue(cfg.mcp.isEmpty) assertTrue(cfg.mcp.isEmpty)
} }
@@ -94,7 +94,7 @@ class AgentikConfigTest {
} }
""".trimIndent(), """.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(1, cfg.mcp.servers.size)
assertEquals("fetch", cfg.mcp.servers.first().name) assertEquals("fetch", cfg.mcp.servers.first().name)
} finally { } finally {
@@ -104,41 +104,41 @@ class AgentikConfigTest {
@Test @Test
fun `skills dir defaults to null`() { fun `skills dir defaults to null`() {
assertEquals(null, AgentikConfig.fromEnv(openAiEnv()).skillsDir) assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.skillsDir)
} }
@Test @Test
fun `skills dir read from env`() { fun `skills dir read from env`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills"))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills")))
assertEquals("/skills", cfg.skillsDir) assertEquals("/skills", cfg.agent.skillsDir)
} }
@Test @Test
fun `blank skills dir falls back to null`() { fun `blank skills dir falls back to null`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " "))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " ")))
assertEquals(null, cfg.skillsDir) assertEquals(null, cfg.agent.skillsDir)
} }
@Test @Test
fun `soul path defaults to null`() { fun `soul path defaults to null`() {
assertEquals(null, AgentikConfig.fromEnv(openAiEnv()).soulPath) assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.soulPath)
} }
@Test @Test
fun `soul path read from env`() { fun `soul path read from env`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md"))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md")))
assertEquals("/etc/SOUL.md", cfg.soulPath) assertEquals("/etc/SOUL.md", cfg.agent.soulPath)
} }
@Test @Test
fun `blank soul path falls back to null`() { fun `blank soul path falls back to null`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " "))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " ")))
assertEquals(null, cfg.soulPath) assertEquals(null, cfg.agent.soulPath)
} }
@Test @Test
fun `serialization round-trips through json`() { fun `serialization round-trips through json`() {
val original = AgentikConfig.fromEnv( val original = AppConfig.fromEnv(
openAiEnv( openAiEnv(
mapOf( mapOf(
"AGENTIK_PORT" to "7777", "AGENTIK_PORT" to "7777",
@@ -148,14 +148,14 @@ class AgentikConfigTest {
), ),
), ),
).copy( ).copy(
mcp = pw.binom.agentik.standalone.mcp.McpConfig( mcp = pw.binom.agentik.mcp.bridge.McpConfig(
servers = listOf( servers = listOf(
pw.binom.agentik.standalone.mcp.McpServerSpec.Stdio( pw.binom.agentik.mcp.bridge.McpServerSpec.Stdio(
name = "fetch", name = "fetch",
command = "uvx", command = "uvx",
args = listOf("mcp-server-fetch"), args = listOf("mcp-server-fetch"),
), ),
pw.binom.agentik.standalone.mcp.McpServerSpec.Http( pw.binom.agentik.mcp.bridge.McpServerSpec.Http(
name = "remote", name = "remote",
url = "https://example.com/mcp", url = "https://example.com/mcp",
headers = mapOf("Authorization" to "Bearer x"), headers = mapOf("Authorization" to "Bearer x"),
@@ -165,35 +165,35 @@ class AgentikConfigTest {
) )
val json = Json { encodeDefaults = true } val json = Json { encodeDefaults = true }
val text = json.encodeToString(AgentikConfig.serializer(), original) val text = json.encodeToString(AppConfig.serializer(), original)
val restored = json.decodeFromString(AgentikConfig.serializer(), text) val restored = json.decodeFromString(AppConfig.serializer(), text)
assertEquals(original, restored) assertEquals(original, restored)
} }
@Test @Test
fun `compressionThreshold defaults to 0_8 when env unset`() { fun `compressionThreshold defaults to 0_8 when env unset`() {
val cfg = AgentikConfig.fromEnv(openAiEnv()) val cfg = AppConfig.fromEnv(openAiEnv())
assertEquals(0.8, cfg.compressionThreshold) assertEquals(0.8, cfg.memory.compressionThreshold)
} }
@Test @Test
fun `compressionThreshold parsed from env`() { fun `compressionThreshold parsed from env`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6"))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6")))
assertEquals(0.6, cfg.compressionThreshold) assertEquals(0.6, cfg.memory.compressionThreshold)
} }
@Test @Test
fun `compressionThreshold clamped between min and max`() { fun `compressionThreshold clamped between min and max`() {
val tooLow = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01"))) val tooLow = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01")))
assertEquals(0.1, tooLow.compressionThreshold) assertEquals(0.1, tooLow.memory.compressionThreshold)
val tooHigh = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5"))) val tooHigh = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5")))
assertEquals(0.99, tooHigh.compressionThreshold) assertEquals(0.99, tooHigh.memory.compressionThreshold)
} }
@Test @Test
fun `compressionThreshold garbage falls back to default`() { fun `compressionThreshold garbage falls back to default`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен"))) val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен")))
assertEquals(0.8, cfg.compressionThreshold) assertEquals(0.8, cfg.memory.compressionThreshold)
} }
} }
@@ -1,14 +1,24 @@
package pw.binom.agentik.standalone.llm package pw.binom.agentik.standalone.llm
import pw.binom.agentik.standalone.config.AppConfig
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
class LlmConfigTest { class LlmConfigTest {
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 @Test
fun `fromEnv — happy path`() { fun `fromEnv — happy path`() {
val cfg = LlmConfig.fromEnv { name -> val cfg = AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test" "OPENAI_API_KEY" -> "sk-test"
@@ -16,28 +26,21 @@ class LlmConfigTest {
"AGENTIK_SYSTEM_PROMPT" -> "be brief" "AGENTIK_SYSTEM_PROMPT" -> "be brief"
else -> null else -> null
} }
} }.llm
assertEquals("be brief", cfg.systemPrompt) assertEquals("be brief", cfg.systemPrompt)
assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai) assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai)
} }
@Test @Test
fun `fromEnv — falls back to default system prompt`() { fun `fromEnv — falls back to default system prompt`() {
val cfg = LlmConfig.fromEnv { name -> val cfg = AppConfig.fromEnv(openAiEnv()).llm
when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini"
else -> null
}
}
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
} }
@Test @Test
fun `fromEnv — missing base url throws`() { fun `fromEnv — missing base url throws`() {
assertFailsWith<IllegalStateException> { assertFailsWith<IllegalStateException> {
LlmConfig.fromEnv { name -> AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_API_KEY" -> "sk-test" "OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini" "OPENAI_MODEL" -> "gpt-4o-mini"
@@ -50,7 +53,7 @@ class LlmConfigTest {
@Test @Test
fun `fromEnv — missing api key throws`() { fun `fromEnv — missing api key throws`() {
assertFailsWith<IllegalStateException> { assertFailsWith<IllegalStateException> {
LlmConfig.fromEnv { name -> AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_MODEL" -> "gpt-4o-mini" "OPENAI_MODEL" -> "gpt-4o-mini"
@@ -63,7 +66,7 @@ class LlmConfigTest {
@Test @Test
fun `fromEnv — missing model throws`() { fun `fromEnv — missing model throws`() {
assertFailsWith<IllegalStateException> { assertFailsWith<IllegalStateException> {
LlmConfig.fromEnv { name -> AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test" "OPENAI_API_KEY" -> "sk-test"
@@ -75,7 +78,7 @@ class LlmConfigTest {
@Test @Test
fun `blank system prompt from env falls back to default`() { fun `blank system prompt from env falls back to default`() {
val cfg = LlmConfig.fromEnv { name -> val cfg = AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test" "OPENAI_API_KEY" -> "sk-test"
@@ -83,13 +86,13 @@ class LlmConfigTest {
"AGENTIK_SYSTEM_PROMPT" -> " " "AGENTIK_SYSTEM_PROMPT" -> " "
else -> null else -> null
} }
} }.llm
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt) assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
} }
@Test @Test
fun `fromEnv — OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() { fun `fromEnv — OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() {
val cfg = LlmConfig.fromEnv { name -> val cfg = AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test" "OPENAI_API_KEY" -> "sk-test"
@@ -97,13 +100,18 @@ class LlmConfigTest {
"OPENAI_CONTEXT_WINDOW" -> "128000" "OPENAI_CONTEXT_WINDOW" -> "128000"
else -> null else -> null
} }
} }.llm
assertEquals(128_000, cfg.openai?.contextWindow) assertEquals(128_000, cfg.openai?.contextWindow)
} }
@Test @Test
fun `resolveContextWindow — env wins over config`() { fun `contextWindow — env parsed once and exposed via derived prop`() {
val cfg = LlmConfig.fromEnv { name -> // Старый resolveContextWindow проверял "env wins over config" — но в новой
// модели env и config это одно и то же значение (env читается один раз в
// AppConfig.fromEnv и сохраняется в OpenAiConfig.contextWindow). Поэтому
// тут проверяем, что derived-prop LlmConfig.contextWindow правильно
// прокидывает значение из OpenAiConfig для активного бэкенда.
val cfg = AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test" "OPENAI_API_KEY" -> "sk-test"
@@ -111,27 +119,19 @@ class LlmConfigTest {
"OPENAI_CONTEXT_WINDOW" -> "64000" "OPENAI_CONTEXT_WINDOW" -> "64000"
else -> null else -> null
} }
} }.llm
// env задаёт 64000; resolveContextWindow возвращает именно его (openai.contextWindow = 64000 уже после fromEnv). assertEquals(64_000, cfg.contextWindow)
assertEquals(64_000, cfg.resolveContextWindow { it })
} }
@Test @Test
fun `resolveContextWindow — returns null when nothing set`() { fun `contextWindow — returns null when nothing set`() {
val cfg = LlmConfig.fromEnv { name -> val cfg = AppConfig.fromEnv(openAiEnv()).llm
when (name) { assertEquals(null, cfg.contextWindow)
"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 })
} }
@Test @Test
fun `fromEnv — OPENAI_CONTEXT_WINDOW garbage falls back to null`() { fun `fromEnv — OPENAI_CONTEXT_WINDOW garbage falls back to null`() {
val cfg = LlmConfig.fromEnv { name -> val cfg = AppConfig.fromEnv { name ->
when (name) { when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1" "OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test" "OPENAI_API_KEY" -> "sk-test"
@@ -139,7 +139,7 @@ class LlmConfigTest {
"OPENAI_CONTEXT_WINDOW" -> "не-число" "OPENAI_CONTEXT_WINDOW" -> "не-число"
else -> null else -> null
} }
} }.llm
assertEquals(null, cfg.openai?.contextWindow) assertEquals(null, cfg.openai?.contextWindow)
} }
} }
@@ -3,6 +3,8 @@ package pw.binom.agentik.standalone.mcp
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import pw.binom.agentik.mcp.bridge.McpConfig
import pw.binom.agentik.mcp.bridge.McpServerSpec
class McpConfigTest { class McpConfigTest {
@@ -1,11 +1,13 @@
package pw.binom.agentik.standalone.mcp package pw.binom.agentik.standalone.mcp
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool import pw.binom.litert.LiteTool
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertSame import kotlin.test.assertSame
import kotlin.test.assertTrue 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 { class McpRegistryTest {