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:
@@ -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\": []}.")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user