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

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

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

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

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

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

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

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

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

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

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

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

(3) AppConfig single source of truth:

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

(4) Other fixes inherited from earlier waves:

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

Test results: 178/178 in :standalone, 36/36 in :agent-toolsets — all green.
This commit is contained in:
2026-09-18 20:43:54 +03:00
parent 25771a0c33
commit ac5d209fce
44 changed files with 833 additions and 518 deletions
@@ -0,0 +1,115 @@
package pw.binom.agentik.llm.tools
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()
}
}
@@ -0,0 +1,137 @@
package pw.binom.agentik.llm.tools
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)
}
@@ -0,0 +1,72 @@
package pw.binom.agentik.llm.tools
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,
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()
}
}
}
@@ -0,0 +1,117 @@
package pw.binom.agentik.llm.tools
/**
* Минимальный парсер 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++
}
}
}
}
@@ -0,0 +1,61 @@
package pw.binom.agentik.llm.tools
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.")
}
}
@@ -0,0 +1,195 @@
package pw.binom.agentik.llm.tools
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
}
}
@@ -0,0 +1,59 @@
package pw.binom.agentik.llm.tools
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:")
}
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.llm.tools
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()
}
}
}
@@ -0,0 +1,207 @@
package pw.binom.agentik.llm.tools
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
}
}
@@ -0,0 +1,80 @@
package pw.binom.agentik.llm.tools
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\": []}.")
}
}