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