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
@@ -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
if (state.isTemporal) return
val userTurnCount = countUserTurnsBlocking()
if (userTurnCount % config.skillMiningInterval != 0) return
val convId = state.id
state.agentScope.launch {
try {
val turns = recentTurnsFromWorkingMemory(miner.maxTurns)
val turns = recentTurns(miner.maxTurns)
if (turns.isEmpty()) return@launch
val existing = store.catalog.skills
val mined = miner.mine(turns, existing)
val mined = miner.mine(turns, store.catalog.skills)
for (s in mined) {
runCatching { store.upsert(s) }
.onFailure { log.warn(it) { "skill-mine upsert '${s.name}' failed: ${it.message}" } }
.onFailure { log.warn(it) { "final skill mining upsert '${s.name}' failed: ${it.message}" } }
}
log.info { "skill-mine: conv=$convId turns=${turns.size} existing=${existing.size} mined=${mined.size}" }
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) { "skill-mine failed for $convId: ${e.message}" }
log.warn(e) { "final skill mining 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++
private fun onCompaction(event: CompactionEvent.Triggered) {
if (state.isTemporal) 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}" }
}
}
count
}
private suspend fun recentTurnsFromWorkingMemory(maxTurns: Int): List<ConversationTurn> {
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 = recentTurns(reflector.maxTurns)
if (turns.isEmpty()) return@launch
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) { "reflection after tool failures failed for $convId: ${e.message}" }
}
}
}
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
}
}
@@ -1,115 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteRole
import kotlin.time.Instant
/**
* Один ход диалога в формате, удобном для суммаризации.
*
* Не тянем из audit log напрямую — работаем со своим упрощённым представлением,
* чтобы compaction не зависел от деталей хранения.
*/
data class SummaryTurn(
val userMessage: String,
val assistantMessage: String,
val createdAt: Instant? = null,
)
/**
* Сжимает список прошлых ходов диалога в короткий markdown-саммари.
*
* Суммаризация — ответственность **агента**, потому что зависит от модели
* (context window, summarization prompt, format). Не кладём в `:memory-api`,
* чтобы модуль памяти не знал про LiteLlm.
*
* Имплементация по умолчанию — [LiteLlmContextCompactor] (один-shot LLM-вызов
* по промпту из Hermes `context_compressor.py`).
*/
fun interface ContextCompactor {
suspend fun summarize(turns: List<SummaryTurn>): String
}
/**
* LLM-реализация [ContextCompactor]. Использует отдельный [LiteConversation]
* без tools и без истории — чистый one-shot вызов, который не загрязняет
* KV-cache основного диалога.
*
* Промпт — структура из Hermes `context_compressor.py`:
* - Goal
* - Active State
* - Resolved
* - Blocked / Open Questions
* - Remaining Work
*
* Возвращает короткий markdown-блок (≈ 10-20 строк), который встанет в
* working memory вместо выкинутых ходов.
*/
class LiteLlmContextCompactor(
private val liteLlm: LiteLlm,
private val modelTemperature: Float = 0.2f,
) : ContextCompactor {
override suspend fun summarize(turns: List<SummaryTurn>): String {
if (turns.isEmpty()) return ""
val transcript = turns.joinToString("\n\n") { turn ->
val stamp = turn.createdAt?.toString()?.let { "[$it] " } ?: ""
buildString {
append(stamp).append("USER: ").append(turn.userMessage.trim()).append('\n')
append(stamp).append("ASSISTANT: ").append(turn.assistantMessage.trim())
}
}
val userPrompt = buildString {
appendLine("Transcript of past turns (oldest first):")
appendLine("```")
append(transcript.take(MAX_TRANSCRIPT_CHARS))
if (transcript.length > MAX_TRANSCRIPT_CHARS) appendLine("…(truncated)")
appendLine("```")
appendLine()
appendLine("Produce a compact context summary in this exact structure:")
appendLine("- **Goal**: one-line primary objective of this conversation")
appendLine("- **Active State**: where we are now / what we are currently doing")
appendLine("- **Resolved**: concrete decisions / outputs that are already done")
appendLine("- **Blocked / Open Questions**: things still unresolved")
appendLine("- **Remaining Work**: explicit next steps")
appendLine()
appendLine("Keep total length under ~20 lines. Plain markdown, no preamble.")
}
val cfg = LiteConversationConfig(
systemInstruction = SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
temperature = modelTemperature,
)
val conv: LiteConversation = liteLlm.createConversation(cfg)
try {
val reply = StringBuilder()
conv.sendStreamContents(listOf(LiteContentPart.Text(userPrompt))).collect { delta ->
if (delta.text.isNotEmpty()) reply.append(delta.text)
}
return reply.toString().trim().ifEmpty { "(empty summary)" }
} finally {
runCatching { conv.close() }
}
}
companion object {
private const val MAX_TRANSCRIPT_CHARS: Int = 24_000
private val SYSTEM_PROMPT = """
You are a context compressor for an ongoing AI conversation. Your job is to
produce a compact structured summary of past turns so that the conversation
can continue without losing the user's goal and current state.
Be terse and concrete. Prefer bullet points over prose. Never invent facts
that are not present in the transcript. Do not address the user — this
summary is for internal use by another LLM.
""".trimIndent()
}
}
@@ -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,72 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
import pw.binom.agentik.storage.Ids
import pw.binom.agentik.storage.Reflection
import kotlin.time.Clock
/**
* One-shot LLM-размышление о качестве последних ходов диалога.
*
* Использует structured-output JSON prompt (так же как [LlmMemoryReviewer]):
* модель возвращает `{ score: 1-5, summary: "...", weakSpots: ["...", "..."] }`,
* парсер [ReflectionParser] возвращает [Reflection].
*
* Триггер: каждые N ходов (AGENTIK_REFLECTION_INTERVAL, default 10).
* Не блокирует основной диалог — вызывается в фоне на [dispatcher].
*
* @param llm LLM-бэкенд
* @param maxTurns сколько последних ходов передавать модели (default 6)
* @param maxTokens размер ответа LLM (default 512)
* @param dispatcher диспетчер для блокирующего LLM-вызова
* @param clock для генерации id/timestamp
*/
class LlmReflector(
private val llm: LiteLlm,
private val maxTurns: Int = 6,
private val maxTokens: Int = 512,
private val dispatcher: CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
private val clock: Clock = Clock.System,
) {
/**
* Reflect по последним [turns]. Возвращает [Reflection] или null если
* парсер не смог распарсить (например модель вернула полную ерунду).
*
* Вызов блокирующий: ~1-3 сек на CPU для on-device LiteRT-LM, ~200-500мс
* для OpenAI. Поэтому в проде всегда вызывается из background scope.
*/
suspend fun reflect(turns: List<ConversationTurn>): Reflection? = withContext(dispatcher) {
require(turns.isNotEmpty()) { "need at least one turn to reflect" }
val conversation = llm.createConversation(
config = LiteConversationConfig(
systemInstruction = ReflectionPrompts.SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
)
)
try {
val userPrompt = ReflectionPrompts.buildUserPrompt(
turns = turns.takeLast(maxTurns),
maxTurns = maxTurns,
)
val raw = conversation.send(userPrompt)
val parsed = ReflectionParser.parse(raw)
?: return@withContext null
Reflection(
id = Ids.reflection(),
conversationId = null, // будет проставлен caller'ом ChatConversation
createdAt = clock.now(),
turnsAnalyzed = turns.size,
score = parsed.score.coerceIn(1, 5),
summary = parsed.summary,
weakSpots = parsed.weakSpots,
)
} finally {
conversation.close()
}
}
}
@@ -1,12 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.litert.LiteTool
/**
* (имя-как-видит-модель) → [LiteTool].
*
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами.
*/
data class NamedTool(val name: String, val tool: LiteTool)
@@ -1,117 +0,0 @@
package pw.binom.agentik.standalone.agent
/**
* Минимальный парсер JSON-ответа от [LlmReflector].
*
* Ожидаемая форма:
* ```
* {"score": 4, "summary": "...", "weakSpots": ["...", "..."]}
* ```
*
* Допуски:
* - Модель может обернуть ответ в ```json ... ``` fences — обрезаем.
* - Может быть лидирующий/завершающий текст до/после JSON — находим первую
* `{` и парсим баланс скобок до парной `}`.
* - `score` может быть числом или строкой ("4") — оба варианта ок.
* - `weakSpots` может быть пустым массивом.
* - Любые невалидные символы → null (defensive: лучше пропустить рефлексию,
* чем уронить agent loop).
*/
object ReflectionParser {
data class Parsed(val score: Int, val summary: String, val weakSpots: List<String>)
fun parse(raw: String): Parsed? {
val json = extractJsonObject(raw) ?: return null
val score = extractIntField(json, "score") ?: return null
val summary = extractStringField(json, "summary") ?: ""
val weakSpots = extractStringArrayField(json, "weakSpots") ?: emptyList()
return Parsed(score = score, summary = summary, weakSpots = weakSpots)
}
/**
* Извлекает JSON-объект из произвольного текста: обрезает ``` fences,
* пропускает префикс/суффикс, ищет первую `{` и парную `}` по балансу скобок.
*/
internal fun extractJsonObject(raw: String): String? {
var s = raw.trim()
// Strip ```json / ``` fences
if (s.startsWith("```")) {
val firstNewline = s.indexOf('\n')
if (firstNewline > 0) s = s.substring(firstNewline + 1)
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
}
val open = s.indexOf('{')
if (open < 0) return null
var depth = 0
var i = open
var inString = false
var escape = false
while (i < s.length) {
val c = s[i]
if (escape) { escape = false; i++; continue }
if (c == '\\' && inString) { escape = true; i++; continue }
if (c == '"') { inString = !inString; i++; continue }
if (!inString) {
when (c) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) return s.substring(open, i + 1)
}
}
}
i++
}
return null
}
/** Достаёт числовое поле из JSON-объекта: "score": 4 или "score": "4". */
internal fun extractIntField(json: String, name: String): Int? {
val re = Regex(""""$name"\s*:\s*(?:(\d+)|"(\d+)")""")
val match = re.find(json) ?: return null
val n = match.groupValues[1].ifEmpty { match.groupValues[2] }
return n.toIntOrNull()
}
/** Достаёт строковое поле: "summary": "..." с `\"` и `\\` escape. */
internal fun extractStringField(json: String, name: String): String? {
val re = Regex(""""$name"\s*:\s*"((?:\\.|[^"\\])*)"""")
val match = re.find(json) ?: return null
return unescape(match.groupValues[1])
}
/** Достаёт массив строк: "weakSpots": ["a", "b"]. Возвращает пустой список если поле отсутствует. */
internal fun extractStringArrayField(json: String, name: String): List<String>? {
val re = Regex(""""$name"\s*:\s*\[([^\]]*)]""")
val match = re.find(json) ?: return null
val inner = match.groupValues[1]
if (inner.isBlank()) return emptyList()
val out = mutableListOf<String>()
val itemRe = Regex("""\"((?:\\.|[^\"\\])*)\"""")
for (m in itemRe.findAll(inner)) {
out.add(unescape(m.groupValues[1]))
}
return out
}
private fun unescape(s: String): String = buildString {
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '\\' && i + 1 < s.length) {
when (s[i + 1]) {
'"' -> append('"')
'\\' -> append('\\')
'n' -> append('\n')
't' -> append('\t')
else -> append(s[i + 1])
}
i += 2
} else {
append(c)
i++
}
}
}
}
@@ -1,61 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.memory.ConversationTurn
/**
* Промпты для [LlmReflector] — one-shot self-reflection.
*
* Стиль: structured-output (модель отвечает JSON, не зовёт тулзы).
* Это та же техника, что в `LlmMemoryReviewer`: on-device LiteRT-LM
* плохо работает с tool-calling, но стабильно отвечает на JSON-prompt
* при явном `respond with JSON` указании.
*/
object ReflectionPrompts {
/**
* System-prompt для размышления.
* Русский — потому что весь остальной agentik тоже ru-flavored
* (review-prompt, MemorySystemGuidance и т.п.).
*/
const val SYSTEM_PROMPT = """Ты — критический аналитик собственной работы ассистента.
Тебе дадут последние ходы диалога: пары user/assistant сообщений.
Оцени, насколько хорошо ассистент справился с задачами пользователя.
Шкала score (одно целое число):
1 — ассистент путался, галлюцинировал, не отвечал на вопрос, игнорировал контекст.
2 — были заметные проблемы (неточные факты, странные ответы).
3 — нормальная работа, ничего особенного.
4 — хорошая работа, помог пользователю, был полезным.
5 — отличная работа: точный, полезный, уместный.
weakSpots — это массив КОРОТКИХ строк (1-3 слова каждая), конкретные слабые
места, которые заметил. Примеры:
- "медленно отвечаю на вопросы про X"
- "путаю A и B"
- "слишком длинные ответы на простые вопросы"
- "не помню контекст разговора"
summary — свободный markdown-комментарий (1-3 предложения): что именно
было хорошо, что плохо, что улучшить.
ВАЖНО: ответь СТРОГО JSON объектом:
{"score": <1-5>, "summary": "<markdown>", "weakSpots": ["...", "..."]}
Никаких пояснений до или после JSON. Только валидный JSON."""
/**
* User-prompt: последние ходы диалога. Каждый ход — пара
* `[user] text` / `[assistant] text`. Старые ходы обрезаются до [maxTurns].
*/
fun buildUserPrompt(turns: List<ConversationTurn>, maxTurns: Int): String = buildString {
appendLine("Последние ${turns.size} из $maxTurns ходов диалога:")
appendLine()
for ((idx, turn) in turns.withIndex()) {
appendLine("--- Ход ${idx + 1} ---")
appendLine("[user]: ${turn.userMessage}")
appendLine("[assistant]: ${turn.assistantMessage}")
appendLine()
}
append("Оцени по шкале и верни JSON.")
}
}
@@ -1,78 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
private val log = mu.KotlinLogging.logger {}
/**
* Фоновый минер скилов (skill mining): сетка безопасности для skill self-improvement.
*
* Модель в ходе разговора может "протупить" и не вызвать `skill_save`, хотя приём
* был действительно переиспользуемым. [SkillMiner] периодически (каждые N ходов,
* см. `AGENTIK_SKILL_MINING_INTERVAL`) берёт последние ходы диалога, показывает их
* LLM вместе с текущим каталогом скилов и просит structured-output JSON:
* {"skills": [{"name","description","body"}]}. Найденные скилы upsert-ятся в
* [pw.binom.agentik.skills.SkillStore] — агент становится умнее между сессиями
* даже без прямого tool-call в ходе разговора.
*
* Архитектурно — точный аналог [LlmReflector]: короткоживущий LiteConversation
* (один прогон = один LLM-вызов), blocking-инференс на [dispatcher], defensive
* парсинг [SkillMiningParser] (кривой ответ → пустой список, не ломает agent loop).
*
* @param llm LLM-бэкенд
* @param maxTurns сколько последних ходов передавать модели (default 30)
* @param maxTokens потолок ответа модели (default 1536 — body скила бывает длинным)
* @param dispatcher диспетчер для блокирующего LLM-вызова
*/
class SkillMiner(
private val llm: LiteLlm,
val maxTurns: Int = 30,
private val maxTokens: Int = 1536,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* Mine по последним [turns] с учётом текущего каталога [existing].
*
* @return найденные/обновлённые скилы; пустой список — нечего сохранять
* или модель ответила мусором (defensive: прогон просто пропускается).
*
* Вызов блокирующий: ~1-3 с на CPU для on-device LiteRT-LM. Всегда вызывать
* из background scope (хук [pw.binom.agentik.standalone.agent.ChatConversation]
* или debug-эндпоинт).
*/
suspend fun mine(turns: List<ConversationTurn>, existing: List<SkillFile>): List<SkillFile> =
withContext(dispatcher) {
val batch = turns.takeLast(maxTurns)
if (batch.isEmpty()) return@withContext emptyList()
val conversation = llm.createConversation(
config = LiteConversationConfig(
systemInstruction = SkillMiningPrompts.SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
temperature = 0.2f,
maxTokens = maxTokens,
)
)
try {
val userPrompt = SkillMiningPrompts.buildUserPrompt(batch, existing)
val raw = conversation.send(userPrompt)
val mined = SkillMiningParser.parse(raw)
if (mined.isNotEmpty()) {
log.info { "skill-mine: found ${mined.size} skill(s) from ${batch.size} turns: ${mined.map { it.name }}" }
}
mined
} catch (e: Throwable) {
log.warn(e) { "skill-mine: LLM call failed, skipping pass" }
emptyList()
} finally {
conversation.close()
}
}
}
@@ -1,207 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillFile
/**
* Минимальный парсер JSON-ответа [SkillMiner] (в том же defensive-стиле, что
* [ReflectionParser] и [ReviewDecisionParser]: без kotlinx-serialization, чтобы
* кривой ответ локальной модели не ронял agent loop).
*
* Ожидаемая форма:
* ```
* {"skills": [{"name": "x", "description": "...", "body": "..."}]}
* ```
* Допуски:
* - ответ может быть обёрнут в ```json ... ``` fences;
* - может быть текст до/после JSON — ищем первый `{` (или `[`) и балансируем;
* - допустим и голый массив `[{...}, {...}]` без ключа `"skills"`;
* - `body` может содержать `\n`, `\"`, `\\` — деэскейпим;
* - `description`/`body` могут отсутствовать (тогда пустые);
* - любой мусор (невалидные кавычки, незакрытые скобки) → пустой список,
* mining-прогон просто не сохранит ничего.
*/
object SkillMiningParser {
fun parse(raw: String): List<SkillFile> {
val blob = extractJsonBlob(raw) ?: return emptyList()
val array = extractSkillsArray(blob) ?: return emptyList()
return splitTopLevelObjects(array).mapNotNull { obj ->
val name = extractStringField(obj, "name")?.trim().orEmpty()
if (name.isEmpty()) return@mapNotNull null
val description = extractStringField(obj, "description")?.trim().orEmpty()
val body = extractStringField(obj, "body")?.trim().orEmpty()
SkillFile(name = name, description = description, body = body)
}
}
/**
* Обрезает ``` fences, находит первый `{` или `[` и возвращает подстроку
* до парной закрывающей (со знанием состояний string/escape).
*/
internal fun extractJsonBlob(raw: String): String? {
var s = raw.trim()
if (s.startsWith("```")) {
val nl = s.indexOf('\n')
if (nl > 0) s = s.substring(nl + 1)
if (s.endsWith("```")) s = s.substring(0, s.length - 3)
}
val open = minOf(
s.indexOf('{').takeIf { it >= 0 } ?: Int.MAX_VALUE,
s.indexOf('[').takeIf { it >= 0 } ?: Int.MAX_VALUE,
)
if (open == Int.MAX_VALUE) return null
val opener = s[open]
val closer = if (opener == '{') '}' else ']'
var depth = 0
var inString = false
var escape = false
for (i in open until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
opener, '{', '[' -> depth++
'}', ']' -> {
depth--
if (depth == 0 && ((c == closer) || (opener == '{' && c == '}') || (opener == '[' && c == ']'))) {
return s.substring(open, i + 1)
}
}
}
}
return null
}
/**
* Находит массив скилов: если в blob есть ключ `"skills"` — массив после него,
* иначе сам blob (если начинается с `[`).
*/
internal fun extractSkillsArray(blob: String): String? {
val keyIdx = blob.indexOf("\"skills\"")
if (keyIdx >= 0) {
val colon = blob.indexOf(':', keyIdx + "skills".length + 2)
if (colon < 0) return null
val open = blob.indexOf('[', colon + 1)
if (open < 0) return null
return balanceArray(blob, open)
}
if (blob.startsWith('[')) return blob.drop(1).dropLast(1)
return null
}
/** Балансирует `[...]` от [start] (включительно). Возвращает содержимое без скобок. */
private fun balanceArray(s: String, start: Int): String? {
var depth = 0
var inString = false
var escape = false
for (i in start until s.length) {
val c = s[i]
if (escape) { escape = false; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
continue
}
when (c) {
'"' -> inString = true
'[' -> depth++
']' -> {
depth--
if (depth == 0) return s.substring(start + 1, i)
}
}
}
return null
}
/** Разбивает содержимое массива на топ-уровневые `{...}` объекты (string-aware). */
internal fun splitTopLevelObjects(arrayContent: String): List<String> {
val out = mutableListOf<String>()
var i = 0
while (i < arrayContent.length) {
if (arrayContent[i] == '{') {
var depth = 0
var inString = false
var escape = false
var j = i
while (j < arrayContent.length) {
val c = arrayContent[j]
if (escape) { escape = false; j++; continue }
if (inString) {
when (c) {
'\\' -> escape = true
'"' -> inString = false
}
} else {
when (c) {
'"' -> inString = true
'{' -> depth++
'}' -> {
depth--
if (depth == 0) {
out.add(arrayContent.substring(i, j + 1))
i = j + 1
break
}
}
}
}
j++
}
if (j >= arrayContent.length) break
} else {
i++
}
}
return out
}
/**
* Достаёт первое строковое поле `"<name>": "..."` из JSON-объекта с
* поддержкой escapes (`\"`, `\\`, `\n`, `\t`). `null` если поля нет.
*/
internal fun extractStringField(obj: String, name: String): String? {
val keyRe = Regex(""""$name"\s*:""")
val keyMatch = keyRe.find(obj) ?: return null
val colonIdx = keyMatch.range.last
// Пропускаем пробельные символы после :
var i = colonIdx + 1
while (i < obj.length && (obj[i] == ' ' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == '\t')) i++
if (i >= obj.length || obj[i] != '"') {
// Значение не строка (null/число) — не поддерживаем.
return null
}
i++ // открывающая кавычка
val sb = StringBuilder()
while (i < obj.length) {
val c = obj[i]
if (c == '\\' && i + 1 < obj.length) {
when (val esc = obj[i + 1]) {
'"' -> sb.append('"')
'\\' -> sb.append('\\')
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
else -> sb.append(esc)
}
i += 2
} else if (c == '"') {
return sb.toString()
} else {
sb.append(c)
i++
}
}
// Незакрытая строка — мусор от модели, считаем null.
return null
}
}
@@ -1,80 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.skills.SkillFile
/**
* Промпты для [SkillMiner]: structured-output JSON.
*
* Тот же подход, что [ReflectionPrompts] и [LlmMemoryReviewer]: локальной модели
* (LiteRT-LM) не доверяем tool-calling, поэтому просим строго JSON и парсим руками
* ([SkillMiningParser]).
*/
object SkillMiningPrompts {
/**
* System prompt. Задаёт роль "минёра скилов": модель смотрит на последние
* ходы разговора и решает, есть ли в них переиспользуемый приём, который
* стоит закрепить в скиле, чтобы агент становился умнее между сессиями.
*
* Ключевое отличие от `skill_save` (в-ходе): mining — это сетка безопасности,
* если модель в ходе разговора забыла сохранить скил. Поэтому промпт жёсткий
* по критериям: только реально переиспользуемое, без дублей каталога.
*/
val SYSTEM_PROMPT: String = """
Ты — фоновый минер навыков (skill miner) для ИИ-агента.
Тебе показывают последние ходы разговора агента с пользователем и каталог
его текущих навыков (скилов). Твоя задача — найти в этих ходах переиспользуемый
приём, процедуру или паттерн, который агент применит в БУДУЩИХ, других
разговорах. Такой приём закрепляется как скил, и агент с ним становится
умнее.
Критерии, ЧТО сохращать:
- конкретная воспроизводимая процедура (шаги, команды, форматы, приёмы);
- решение проблемы, которое модель выработала и в другом разговоре повторит;
- проверка/валидация, о которой модель "забыла" и её стоит закрепить.
ЧТО НЕ сохраняй:
- разовые факты конкретного разговора (это не приём, а данные);
- тривиальность ("ответь кратко"), которую и так видно из контекста;
- дубли уже существующих скилов в каталоге — если приём уже есть,
предложи ОБНОВЛЕНИЕ (то же имя, улучшённый body), а не новый скил.
Вывод — строго JSON, без пояснений до и после:
{"skills": [{"name": "...", "description": "...", "body": "..."}]}
Если сохранять нечего — {"skills": []}
Правила по полям:
- name: kebab-case или иерархия через двоеточие (например, "backend:spring:db-base");
короткое, 2-5 слов. Если обновляешь существующий скил — ТОЧНО его имя.
- description: 1-2 предложения, когда/зачем применять скил.
- body: markdown-инструкция: краткое описание + нумерованные шаги + примеры.
Только то, что агент должен помнить; без воды.
""".trimIndent()
/**
* User prompt: последние [turns] разговора + каталог существующих скилов.
* Ходы нумеруются, чтобы модель понимала хронологию.
*/
fun buildUserPrompt(turns: List<ConversationTurn>, existing: List<SkillFile>): String = buildString {
appendLine("### Последние ходы разговора (по хронологии)")
turns.forEachIndexed { i, t ->
appendLine()
appendLine("--- Ход ${i + 1} ---")
appendLine("[user] ${t.userMessage.take(1500)}")
appendLine("[assistant] ${t.assistantMessage.take(2000)}")
}
appendLine()
appendLine("### Текущий каталог скилов (не дубли, обновляй при необходимости)")
if (existing.isEmpty()) {
appendLine("(пока нет)")
} else {
for (s in existing) {
appendLine("- ${s.name}: ${s.description.take(160)}")
}
}
appendLine()
appendLine("Если есть что сохранить (или обновить существующий) — выведи JSON. Иначе {\"skills\": []}.")
}
}
@@ -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(
@@ -1,137 +0,0 @@
package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.ConversationTurn
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.storage.Ids
import pw.binom.litert.LiteLlm
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Реализация [MemoryReviewer] поверх on-device LLM (LiteLlm / Google LiteRT-LM).
*
* После каждого хода (или пачки ходов при compaction) зовём LiteLlm с
* специальным промптом, который просит модель вернуть JSON со списком
* новых заметок и удалений. Парсим руками (см. [ReviewDecisionParser]) —
* on-device модели с tool-calling работают ненадёжно, structured output
* стабильнее.
*
* Конструктор принимает `dispatcher` чтобы I/O LiteLlm не блокировал
* основной поток. По умолчанию — `Dispatchers.IO` (вытягивается из контекста).
*
* @param maxExistingFacts сколько последних заметок подмешивать в промпт
* как [memory-context], чтобы модель не дублировала уже сохранённые факты.
*/
class LlmMemoryReviewer(
private val liteLlm: LiteLlm,
private val store: MemoryStore,
private val dispatcher: CoroutineDispatcher,
private val maxExistingFacts: Int = 30,
private val clock: Clock = Clock.System,
) : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = withContext(dispatcher) {
// Снимок существующих заметок — чтобы модель не дублировала
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
val prompt = ReviewPrompts.reviewUserPrompt(turn, existingFacts)
val conv = liteLlm.createConversation(
pw.binom.litert.LiteConversationConfig(
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
temperature = 0.2f,
maxTokens = 512,
)
)
val raw = try {
conv.send(prompt)
} finally {
conv.close()
}
ReviewDecisionParser.parse(raw)
}
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision = withContext(dispatcher) {
if (turns.isEmpty()) return@withContext MemoryReviewDecision()
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
// Склеиваем все ходы в один промпт — модель посмотрит пакетом и сможет
// отсеять дубликаты между ходами.
val prompt = buildString {
if (existingFacts.isNotBlank()) {
appendLine("[memory-context — что уже сохранено]")
appendLine(existingFacts)
appendLine()
}
appendLine("[compacted-turns — будет удалено после compaction'а]")
turns.forEachIndexed { idx, t ->
appendLine()
appendLine("--- turn ${idx + 1} ---")
appendLine("[user] ${t.userMessage}")
appendLine("[assistant] ${t.assistantMessage}")
}
appendLine()
append("Верни JSON:")
}
val conv = liteLlm.createConversation(
pw.binom.litert.LiteConversationConfig(
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
temperature = 0.2f,
maxTokens = 1024,
)
)
val raw = try {
conv.send(prompt)
} finally {
conv.close()
}
ReviewDecisionParser.parse(raw)
}
/**
* Применяет решение к store: сохраняет новые заметки, удаляет помеченные.
* Возвращает сколько заметок записано/удалено — для метрик.
*/
suspend fun apply(decision: MemoryReviewDecision, source: pw.binom.agentik.memory.MemorySource): ApplyResult = withContext(dispatcher) {
var saved = 0
var deleted = 0
for (note in decision.toSave) {
store.upsert(toMemoryNote(note, source))
saved++
}
for (id in decision.toDelete) {
if (store.delete(id)) deleted++
}
ApplyResult(saved = saved, deleted = deleted)
}
private fun toMemoryNote(
note: NewMemoryNote,
source: pw.binom.agentik.memory.MemorySource,
): pw.binom.agentik.memory.MemoryNote {
val now = clock.now()
return pw.binom.agentik.memory.MemoryNote(
id = Ids.new("mem-review"),
category = note.category,
content = note.content,
createdAt = now,
lastUsedAt = now,
useCount = 0,
source = source,
)
}
data class ApplyResult(val saved: Int, val deleted: Int)
}
@@ -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,195 +0,0 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.NewMemoryNote
/**
* Парсер ответа LLM-review-loop'а.
*
* LiteLlm (on-device) не имеет надёжного tool-calling flow, поэтому
* модель возвращает JSON в plain text. Парсим регуляркой + минимальным
* валидатором — если что-то не так, лучше no-op, чем краш.
*/
object ReviewDecisionParser {
/**
* Парсит ответ модели в [MemoryReviewDecision]. Возвращает пустой decision
* если ответ пустой, не JSON, или JSON битый — лучше ничего не сохранить,
* чем записать мусор.
*/
fun parse(rawOutput: String): MemoryReviewDecision {
val trimmed = rawOutput.trim()
if (trimmed.isEmpty()) return MemoryReviewDecision()
// Ищем JSON-блок, даже если модель обернула его в ``` или добавила пояснения
val json = extractJson(trimmed) ?: return MemoryReviewDecision()
return parseJson(json)
}
private fun extractJson(text: String): String? {
// Первый '{' до последней '}'
val start = text.indexOf('{')
val end = text.lastIndexOf('}')
if (start < 0 || end < 0 || end <= start) return null
return text.substring(start, end + 1)
}
/**
* Минимальный JSON-парсер. Не хочу тянуть kotlinx-serialization в этот
* слой — JSON простой (плоский массив объектов), пишем руками.
*/
private fun parseJson(json: String): MemoryReviewDecision {
return try {
val save = parseArray(json, "save") { obj ->
val category = parseString(obj, "category")?.let { runCatching { MemoryCategory.valueOf(it.uppercase()) }.getOrNull() }
?: return@parseArray null
val content = parseString(obj, "content")?.takeIf { it.isNotBlank() }
?: return@parseArray null
NewMemoryNote(category, content)
}
val delete = parseStringArray(json, "delete")
MemoryReviewDecision(toSave = save, toDelete = delete)
} catch (e: Exception) {
MemoryReviewDecision()
}
}
/**
* Парсит массив объектов из JSON-строки по ключу. callback получает
* содержимое одного элемента (без обрамляющих []{} и без имени ключа)
* и возвращает элемент результата либо null (пропустить).
*/
private fun <T> parseArray(json: String, key: String, map: (String) -> T?): List<T> {
// Ищем "key": [ ... ]
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return emptyList()
val arrayStart = json.indexOf('[', keyIdx)
val arrayEnd = json.indexOf(']', arrayStart)
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
return splitTopLevelObjects(arrayContent).mapNotNull { map(it) }
}
/**
* Разбивает содержимое JSON-массива на отдельные объекты верхнего уровня
* с учётом вложенности и экранирования кавычек.
*/
private fun splitTopLevelObjects(content: String): List<String> {
val result = mutableListOf<String>()
var depth = 0
var start = -1
var inString = false
var escaped = false
content.forEachIndexed { i, ch ->
if (escaped) { escaped = false; return@forEachIndexed }
when {
ch == '\\' && inString -> escaped = true
ch == '"' -> inString = !inString
!inString && ch == '{' -> {
if (depth == 0) start = i
depth++
}
!inString && ch == '}' -> {
depth--
if (depth == 0 && start >= 0) {
result.add(content.substring(start, i + 1))
start = -1
}
}
}
}
return result
}
/**
* Парсит массив строк из JSON по ключу. Используется для `delete`
* (там элементы — голые строки, не объекты).
*/
private fun parseStringArray(json: String, key: String): List<String> {
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return emptyList()
val arrayStart = json.indexOf('[', keyIdx)
val arrayEnd = json.indexOf(']', arrayStart)
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
val result = mutableListOf<String>()
var i = 0
while (i < arrayContent.length) {
// Skip whitespace and commas
while (i < arrayContent.length && (arrayContent[i].isWhitespace() || arrayContent[i] == ',')) i++
if (i >= arrayContent.length || arrayContent[i] != '"') break
i++ // skip opening quote
val sb = StringBuilder()
var escaped = false
while (i < arrayContent.length) {
val c = arrayContent[i]
if (escaped) {
when (c) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(c)
}
escaped = false
i++
continue
}
when (c) {
'\\' -> { escaped = true; i++ }
'"' -> {
result.add(sb.toString())
i++
break
}
else -> { sb.append(c); i++ }
}
}
}
return result
}
/**
* Парсит строковое значение по ключу в JSON-объекте. Возвращает
* содержимое без обрамляющих кавычек и с раскрытыми базовыми escape.
*/
private fun parseString(obj: String, key: String): String? {
val keyIdx = obj.indexOf("\"$key\"")
if (keyIdx < 0) return null
val colon = obj.indexOf(':', keyIdx)
if (colon < 0) return null
val firstQuote = obj.indexOf('"', colon)
if (firstQuote < 0) return null
// Ищем закрывающую кавычку с учётом escape
var i = firstQuote + 1
val sb = StringBuilder()
while (i < obj.length) {
val c = obj[i]
when {
c == '\\' && i + 1 < obj.length -> {
when (val next = obj[i + 1]) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(next)
}
i += 2
}
c == '"' -> return sb.toString()
else -> {
sb.append(c)
i++
}
}
}
return null
}
}
@@ -1,59 +0,0 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.memory.ReviewedTurn
/**
* Промпты для review-loop'а через LiteLlm.
*
* Hermes делает это с OpenAI/Anthropic tool-calling flow. У нас on-device
* движок (Google LiteRT-LM) — там tool-calling ненадёжен, поэтому используем
* structured-output: модель должна вернуть JSON, который мы парсим регуляркой.
*/
object ReviewPrompts {
/**
* Системная инструкция для review-loop'а. На русском — модель у нас
* русскоязычная (gemma-2-9b / qwen / и т.п.), английский промпт часто
* даёт хуже результат на ru-данных.
*/
const val REVIEW_SYSTEM_PROMPT = """Ты — агент ревью памяти. Твоя задача — проанализировать пару (сообщение пользователя, ответ ассистента) и решить, что из неё стоит сохранить в долговременную память.
Категории памяти:
- USER — факты о пользователе (имя, профессия, предпочтения, контекст его жизни)
- WORLD — факты о внешнем мире (проекты, технологии, организации, конкретные API/документация)
- PREFERENCE — предпочтения по формату/поведению ассистента (стиль кода, длины ответов, инструменты)
Правила:
1. Сохраняй ТОЛЬКО durable facts — то, что останется актуальным через недели. Не сохраняй "пользователь поздоровался" или "ассистент использовал grep".
2. Не дублируй уже сохранённое — если факт уже есть в [memory-context], пропусти.
3. Каждый факт — одна короткая фраза. Не абзацы, не "the user mentioned...".
4. Не выдумывай. Если ничего достойного — верни пустой массив.
Формат ответа — строго JSON без обрамления ```json и без пояснений:
{"save":[{"category":"USER|WORLD|PREFERENCE","content":"..."}],"delete":[]}
Если нечего сохранять:
{"save":[],"delete":[]}"""
/**
* Форматирует user-prompt для review-loop'а. Подаёт текущий ход +
* текущее состояние долговременной памяти (чтобы избежать дубликатов).
*/
fun reviewUserPrompt(
turn: ReviewedTurn,
existingFacts: String,
): String = buildString {
if (existingFacts.isNotBlank()) {
appendLine("[memory-context — что уже сохранено]")
appendLine(existingFacts)
appendLine()
}
appendLine("[user]")
appendLine(turn.userMessage)
appendLine()
appendLine("[assistant]")
appendLine(turn.assistantMessage)
appendLine()
append("Верни JSON:")
}
}
@@ -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,
) {
@@ -1,105 +0,0 @@
package pw.binom.agentik.standalone.mcp
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
/**
* Описание одного MCP-сервера (см. [McpRegistry]).
*
* Два варианта транспорта:
* - [Stdio]: запустить процесс (`command` + `args`), общаться через stdin/stdout
* - [Http]: подключиться к удалённому MCP-серверу по URL (Streamable HTTP)
*/
@Serializable
sealed interface McpServerSpec {
/** Уникальное имя сервера, как его видно в логах и в tool-prefix. */
val name: String
/** stdio: спавним процесс, читаем его stdout, пишем в stdin. */
@Serializable
@SerialName("stdio")
data class Stdio(
override val name: String,
val command: String,
val args: List<String> = emptyList(),
val env: Map<String, String> = emptyMap(),
) : McpServerSpec
/** http (Streamable HTTP transport): подключаемся к существующему MCP-серверу. */
@Serializable
@SerialName("http")
data class Http(
override val name: String,
val url: String,
val headers: Map<String, String> = emptyMap(),
) : McpServerSpec
}
/**
* Конфигурация MCP-слоя standalone'а.
*
* Парсится из JSON-файла в формате, совместимом с Claude Desktop
* (`mcpServers.{name}.command/args` для stdio, `mcpServers.{name}.url` для http).
*
* Путь к файлу — env `AGENTIK_MCP_CONFIG`. Если не задан или файл не существует — пустой список.
*/
@Serializable
data class McpConfig(
val servers: List<McpServerSpec> = emptyList(),
) {
val isEmpty: Boolean get() = servers.isEmpty()
companion object {
private val log = mu.KotlinLogging.logger {}
private val json = Json { ignoreUnknownKeys = true }
fun fromEnv(env: (String) -> String? = System::getenv): McpConfig {
val path = env("AGENTIK_MCP_CONFIG")?.takeIf { it.isNotBlank() } ?: return empty()
val file = File(path)
if (!file.exists()) {
log.warn { "AGENTIK_MCP_CONFIG points to missing file: $path" }
return empty()
}
return fromJson(file.readText())
}
fun empty(): McpConfig = McpConfig(servers = emptyList())
fun fromJson(raw: String): McpConfig {
val root = json.parseToJsonElement(raw).jsonObject
val mcpServers = root["mcpServers"]?.jsonObject ?: return empty()
val servers = mcpServers.entries.mapNotNull { (name, spec) -> parseServer(name, spec.jsonObject) }
return McpConfig(servers)
}
private fun parseServer(name: String, spec: JsonObject): McpServerSpec? {
val url = (spec["url"] as? JsonPrimitive)?.jsonPrimitive?.content
if (url != null) {
val headers = (spec["headers"] as? JsonObject)?.entries
?.associate { (k, v) -> k to (v as JsonPrimitive).jsonPrimitive.content }
?: emptyMap()
return McpServerSpec.Http(name = name, url = url, headers = headers)
}
val command = (spec["command"] as? JsonPrimitive)?.jsonPrimitive?.content
if (command != null) {
val args = (spec["args"] as? kotlinx.serialization.json.JsonArray)
?.map { (it as JsonPrimitive).jsonPrimitive.content }
?: emptyList()
val env = (spec["env"] as? JsonObject)?.entries
?.associate { (k, v) -> k to (v as JsonPrimitive).jsonPrimitive.content }
?: emptyMap()
return McpServerSpec.Stdio(name = name, command = command, args = args, env = env)
}
log.warn { "MCP server '$name' has neither 'url' nor 'command' — skipped" }
return null
}
}
}
@@ -1,259 +0,0 @@
package pw.binom.agentik.standalone.mcp
import mu.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.defaultRequest
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.Tool
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
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
/**
* Реестр подключённых MCP-серверов.
*
* На старте подключается ко всем [McpServerSpec] из [McpConfig], у каждого запрашивает
* список tools и оборачивает их в [LiteTool]-адаптеры ([McpLiteToolAdapter]). Все
* адаптеры собираются в [allTools], который `ChatConversation` подмешивает в
* [pw.binom.litert.LiteConversationConfig.tools].
*
* [close] убивает stdio-процессы и закрывает HTTP-клиент.
*/
private val log = KotlinLogging.logger {}
class McpRegistry(
private val servers: List<McpServerSpec>,
private val httpClient: HttpClient = defaultHttpClient(),
private val clientName: String = "agentik",
private val clientVersion: String = "1.0.0",
) : AutoCloseable {
private val connected: MutableMap<String, ConnectedServer> = ConcurrentHashMap()
@Volatile private var closed = false
/** Все [LiteTool] со всех подключённых серверов. */
val allTools: List<LiteTool> by lazy {
connected.values.flatMap { it.tools }
}
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
val namedTools: List<NamedTool> by lazy {
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
}
/** Количество успешно подключённых серверов. */
val connectedServerCount: Int get() = connected.size
init {
if (servers.isNotEmpty()) {
for (spec in servers) {
runCatching {
val server = connectOne(spec)
connected[spec.name] = server
}.onFailure { e ->
log.warn { "MCP server '${spec.name}' failed to connect: ${e.message}" }
}
}
log.warn { "MCP registry: ${connected.size}/${servers.size} servers connected, ${allTools.size} tools total" }
}
}
private fun connectOne(spec: McpServerSpec): ConnectedServer {
var ownedProcess: Process? = null
val transport: Transport = when (spec) {
is McpServerSpec.Stdio -> {
val cmd = (listOf(spec.command) + spec.args).joinToString(" ")
log.warn { "MCP stdio '$spec.name': $cmd" }
val pb = ProcessBuilder(buildList { add(spec.command); addAll(spec.args) })
.redirectErrorStream(false)
spec.env.forEach { (k, v) -> pb.environment()[k] = v }
val proc = pb.start()
ownedProcess = proc
StdioClientTransport(
input = proc.inputStream.asSource().buffered(),
output = proc.outputStream.asSink().buffered(),
error = proc.errorStream.asSource().buffered(),
)
}
is McpServerSpec.Http -> {
log.warn { "MCP http '$spec.name': ${spec.url}" }
StreamableHttpClientTransport(
client = httpClient.config {
if (spec.headers.isNotEmpty()) {
defaultRequest {
spec.headers.forEach { (k, v) ->
headers { append(k, v) }
}
}
}
},
url = spec.url,
)
}
}
val client = Client(
clientInfo = Implementation(name = clientName, version = clientVersion, title = null, websiteUrl = null, icons = emptyList()),
)
runBlocking { client.connect(transport) }
val mcpTools = runBlocking { client.listTools().tools }
val liteTools = mcpTools.map { tool -> McpLiteToolAdapter(spec.name, tool, client) }
return ConnectedServer(spec, client, transport, ownedProcess, liteTools)
}
override fun close() {
if (closed) return
closed = true
connected.values.forEach { server ->
runCatching { runBlocking { server.transport.close() } }
server.ownedProcess?.let { runCatching { it.destroyForcibly() } }
}
connected.clear()
runCatching { httpClient.close() }
}
private data class ConnectedServer(
val spec: McpServerSpec,
val client: Client,
val transport: Transport,
val ownedProcess: Process?,
val tools: List<LiteTool>,
)
companion object {
private fun defaultHttpClient(): HttpClient = HttpClient(CIO)
fun fromConfig(config: McpConfig): McpRegistry =
McpRegistry(servers = config.servers)
}
}
/**
* Адаптер MCP [Tool] → [pw.binom.litert.LiteTool].
*
* Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий
* между MCP-серверами (например, оба могут иметь tool `search`).
*
* [describe] сериализует tool в JSON-дескриптор — flat OpenAPI-спецификация
* (формат, который напрямую принимает LiteRT-LM; litert-openai сам оборачивает
* её в OpenAI-формат):
*
* ```json
* {
* "name": "<server>__<tool>",
* "description": "...",
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
* }
* ```
*
* [invoke] вызывает [Client.callTool] по оригинальному (непрефиксованному) имени тула
* на нужном MCP-сервере и схлопывает [CallToolResult] в плоский текст:
* для каждого блока контента возвращается либо `text`, либо JSON-представление.
* Если `isError == true` — текст префиксуется `[tool error]`.
*/
internal class McpLiteToolAdapter(
private val serverName: String,
private val tool: Tool,
private val client: Client,
) : LiteTool {
internal val fullName: String = "${serverName}__${tool.name}"
override fun describe(): String =
buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат LiteRT-LM.
// litert-openai оборачивает её в OpenAI-формат сам (normalizeToolDescriptor).
put("name", fullName)
put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema())
}.toString()
override fun invoke(arguments: String): String {
val argsMap = parseArgsJson(arguments, tool.name)
val result: CallToolResult = runBlocking { client.callTool(tool.name, argsMap) }
return renderResult(result)
}
private fun renderResult(result: CallToolResult): String {
val isError = result.isError == true
val parts = result.content.map { block ->
when (block) {
is TextContent -> block.text
else -> Json.encodeToString(JsonElement.serializer(), JsonPrimitive(block.toString()))
}
}
val text = parts.joinToString("\n").ifEmpty { "[]" }
return if (isError) "[tool error] $text" else text
}
private fun ToolSchema?.toJsonSchema(): JsonElement {
if (this == null) return buildJsonObject { put("type", "object") }
val props = this.properties ?: buildJsonObject { }
val reqs = this.required ?: emptyList()
return buildJsonObject {
put("type", this@toJsonSchema.type.ifEmpty { "object" })
put("properties", props)
put("required", JsonArray(reqs.map { JsonPrimitive(it) }))
}
}
companion object {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private fun parseArgsJson(raw: String, toolName: String): Map<String, Any?> {
if (raw.isBlank()) return emptyMap()
return try {
val parsed = json.parseToJsonElement(raw)
if (parsed !is JsonObject) emptyMap() else parsed.toAnyMap()
} catch (e: Throwable) {
log.warn { "MCP tool '$toolName' got invalid args JSON: ${e.message}" }
emptyMap()
}
}
private fun JsonObject.toAnyMap(): Map<String, Any?> =
entries.associate { (k, v) -> k to jsonElementToAny(v) }
/**
* Преобразует [JsonPrimitive] в типизированное значение (Boolean/Int/Long/Float/Double),
* и только в крайнем случае — в String. Без этого MCP-сервер получает все аргументы
* как строки и отвергает их по JSON-schema (например, `max_length:500` → `"500"` →
* «'500' is not of type 'integer'»).
*/
private fun jsonElementToAny(el: JsonElement): Any? = when (el) {
is JsonPrimitive ->
el.booleanOrNull
?: el.intOrNull
?: el.longOrNull
?: el.doubleOrNull
?: if (el.isString) el.content else el.content
is JsonArray -> el.map { jsonElementToAny(it) }
is JsonObject -> el.toAnyMap()
else -> 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 {