refactor(standalone): extract modules, event-driven background, AppConfig
ci / JVM build + tests (push) Failing after 2m5s
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:
@@ -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)
|
||||
}
|
||||
+148
-92
@@ -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() {
|
||||
|
||||
+11
@@ -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()
|
||||
}
|
||||
}
|
||||
+18
-10
@@ -48,6 +48,10 @@ import pw.binom.litert.LiteTool
|
||||
import pw.binom.litert.LiteToolCall
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Instant
|
||||
import pw.binom.agentik.llm.tools.LlmReflector
|
||||
import pw.binom.agentik.llm.tools.SkillMiner
|
||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
class ConversationLoop(
|
||||
record: ConversationRecord,
|
||||
@@ -59,16 +63,13 @@ class ConversationLoop(
|
||||
private val memoryPrefetcher: MemoryPrefetcher? = null,
|
||||
private val memoryReviewer: MemoryReviewer? = null,
|
||||
private val memoryStoreForReview: MemoryStore? = null,
|
||||
private val memoryReviewInterval: Int = 0,
|
||||
private val contextWindow: Int? = null,
|
||||
private val compressionThreshold: Double = 0.8,
|
||||
private val contextCompactor: ContextCompactor? = null,
|
||||
private val reflectionStore: ReflectionStore? = null,
|
||||
private val reflector: LlmReflector? = null,
|
||||
private val reflectionInterval: Int = 0,
|
||||
private val skillMiner: SkillMiner? = null,
|
||||
private val skillMiningStore: SkillStore? = null,
|
||||
private val skillMiningInterval: Int = 0,
|
||||
) : ProtoConversation, AutoCloseable {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
@@ -85,6 +86,9 @@ class ConversationLoop(
|
||||
|
||||
private val events = ConversationEvents()
|
||||
|
||||
/** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */
|
||||
private val backgroundEvents = BackgroundEventBus()
|
||||
|
||||
private val conversationStore: ConversationStore get() = storage.conversationStore
|
||||
private val messageStore: MessageStore get() = storage.messageStore
|
||||
private val workingMemory: WorkingMemoryStore get() = storage.workingMemoryStore
|
||||
@@ -103,12 +107,14 @@ class ConversationLoop(
|
||||
workingMemory = workingMemory,
|
||||
liteLlm = llm,
|
||||
systemPrompt = systemPrompt,
|
||||
backgroundEvents = backgroundEvents,
|
||||
)
|
||||
|
||||
private val toolDispatcher = ToolDispatcher(
|
||||
state = state,
|
||||
messageStore = messageStore,
|
||||
events = events,
|
||||
backgroundEvents = backgroundEvents,
|
||||
toolsByName = toolsByName,
|
||||
toolsetDispatch = toolsetDispatch,
|
||||
newId = ::newId,
|
||||
@@ -122,15 +128,13 @@ class ConversationLoop(
|
||||
config = BackgroundConfig(
|
||||
memoryReviewer = memoryReviewer,
|
||||
memoryStore = memoryStoreForReview,
|
||||
memoryReviewInterval = memoryReviewInterval,
|
||||
reflectionStore = reflectionStore,
|
||||
reflector = reflector,
|
||||
reflectionInterval = reflectionInterval,
|
||||
skillMiner = skillMiner,
|
||||
skillMiningStore = skillMiningStore,
|
||||
skillMiningInterval = skillMiningInterval,
|
||||
),
|
||||
)
|
||||
backgroundEvents = backgroundEvents,
|
||||
).also { it.start(agentScope) }
|
||||
|
||||
override val id: String get() = state.id
|
||||
override val isSupportImageInput: Boolean get() = false
|
||||
@@ -210,6 +214,10 @@ class ConversationLoop(
|
||||
override fun close() {
|
||||
if (state.isClosed) return
|
||||
state.markClosed()
|
||||
// Emit Closing event BEFORE agentScope.cancel() — BackgroundScheduler's подписка
|
||||
// ловит это и делает final reflection + skill mining (last chance вытащить insights).
|
||||
// После cancel() подписка умерла бы.
|
||||
backgroundEvents.tryEmit(ConversationLifecycleEvent.Closing(conversationId = id))
|
||||
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
|
||||
runCatching { runBlocking { activeTurn?.cancelAndJoin() } }
|
||||
agentScope.cancel()
|
||||
@@ -420,9 +428,9 @@ class ConversationLoop(
|
||||
state.record = state.record.copy(updatedAt = assistantAt)
|
||||
conversationStore.touch(id, assistantAt)
|
||||
|
||||
backgroundScheduler.maybeScheduleReview(userRecord, assistantContent)
|
||||
backgroundScheduler.maybeScheduleReflection(userRecord, assistantContent)
|
||||
backgroundScheduler.maybeScheduleSkillMining(userRecord, assistantContent)
|
||||
// BackgroundScheduler is event-driven — подписан на BackgroundEventBus
|
||||
// (compaction/lifecycle/tool-failure events). Никаких interval-based
|
||||
// вызовов сюда больше не идёт. См. BackgroundScheduler.kt.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import pw.binom.agentik.storage.ConversationRecord
|
||||
import pw.binom.litert.LiteConversation
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import pw.binom.agentik.toolsets.NamedTool
|
||||
|
||||
internal class ConversationState(
|
||||
initialRecord: ConversationRecord,
|
||||
|
||||
@@ -1,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(
|
||||
|
||||
-137
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
-195
@@ -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
|
||||
}
|
||||
}
|
||||
-59
@@ -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'а
|
||||
|
||||
+49
-12
@@ -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"
|
||||
}
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
|
||||
+43
-43
@@ -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 {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user