Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s
ci / JVM build + tests (push) Successful in 6m46s
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
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.Default,
|
||||
) {
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import pw.binom.agentik.agent.Component
|
||||
import pw.binom.agentik.agent.ConversationAware
|
||||
import pw.binom.agentik.agent.ConversationHandle
|
||||
import pw.binom.agentik.agent.MutableAgent
|
||||
import pw.binom.agentik.agent.SystemPromptProvider
|
||||
import pw.binom.agentik.agent.ToolProvider
|
||||
import pw.binom.agentik.skill.mining.SkillMiner
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.skills.SkillStore
|
||||
import pw.binom.agentik.skills.renderSystemPromptSection
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* События, по которым SkillMiningComponent решает, что пора майнить новые скилы.
|
||||
*
|
||||
* Standalone-часть мэпит свой [pw.binom.agentik.standalone.agent.BackgroundEventBus]
|
||||
* на этот sealed interface и подаёт результат в [SkillMiningComponent.events].
|
||||
* Делаем так, чтобы модуль :skill-mining не зависел от :standalone и его
|
||||
* внутренних типов.
|
||||
*/
|
||||
sealed interface SkillMiningEvent {
|
||||
val conversationId: String
|
||||
|
||||
/** Разговор закрывается — последний шанс вытащить скилы из контекста. */
|
||||
data class ConversationClosing(override val conversationId: String) : SkillMiningEvent
|
||||
|
||||
/** Сработал compactor — старые turn'ы ушли, mining нужен на новом контексте. */
|
||||
data class ConversationCompacted(override val conversationId: String) : SkillMiningEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная подсистема скилов как один Component:
|
||||
* 1. Тулы skill_save / skill_delete / skill_read — через [SkillToolProvider].
|
||||
* 2. Секция системного промпта с актуальным каталогом — через [SkillSystemProvider].
|
||||
* 3. Фоновый майнер — на [SkillMiningEvent.ConversationClosing] /
|
||||
* [SkillMiningEvent.ConversationCompacted] вызывает [SkillMiner] над
|
||||
* последними turn'ами разговора и пишет новые скилы в [SkillStore].
|
||||
*
|
||||
* Поведение майнера зависит от per-conversation state ([ConversationHandle]):
|
||||
* - isTemporal=true → пропускаем (mining не имеет смысла для temp-бесед);
|
||||
* - recentTurns(limit) → что майнер видит.
|
||||
*
|
||||
* Подключается как обычный Component: `agent.install(SkillMiningComponent(...))`.
|
||||
* Дополнительно компонент реализует [ConversationAware] — [MutableAgent]
|
||||
* оповещает его о создании/закрытии каждого разговора.
|
||||
*/
|
||||
class SkillMiningComponent(
|
||||
private val store: SkillStore,
|
||||
private val llm: LiteLlm,
|
||||
private val events: Flow<SkillMiningEvent>,
|
||||
private val scope: CoroutineScope,
|
||||
) : Component, ConversationAware {
|
||||
|
||||
private var toolProvider: SkillToolProvider? = null
|
||||
private var systemProvider: SkillSystemProvider? = null
|
||||
private var minerJob: Job? = null
|
||||
|
||||
/** Per-conversation context для mining: id → handle. */
|
||||
private val handles = mutableMapOf<String, ConversationHandle>()
|
||||
private val handlesLock = Mutex()
|
||||
|
||||
override fun install(agent: MutableAgent) {
|
||||
val tp = SkillToolProvider(store)
|
||||
agent.toolProviders.add(tp)
|
||||
toolProvider = tp
|
||||
|
||||
val sp = SkillSystemProvider(store)
|
||||
agent.systemProviders.add(sp)
|
||||
systemProvider = sp
|
||||
|
||||
// Подписка на фоновые события — единая на компонент (не на разговор).
|
||||
minerJob = scope.launch {
|
||||
events.collect { event ->
|
||||
when (event) {
|
||||
is SkillMiningEvent.ConversationClosing,
|
||||
is SkillMiningEvent.ConversationCompacted -> {
|
||||
mineForConversation(event.conversationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun uninstall(agent: MutableAgent) {
|
||||
minerJob?.cancel()
|
||||
minerJob = null
|
||||
toolProvider?.let { agent.toolProviders.remove(it) }
|
||||
systemProvider?.let { agent.systemProviders.remove(it) }
|
||||
// очистка под mutex: detach-вызовы могут быть в полёте из других корутин
|
||||
runBlocking { handlesLock.withLock { handles.clear() } }
|
||||
}
|
||||
|
||||
/** [ConversationAware]: новый разговор — запоминаем handle. */
|
||||
override fun attachConversation(handle: ConversationHandle) {
|
||||
// attach вызывается синхронно из MutableAgent.install-flow,
|
||||
// целостность важна только между attach/detach и чтением из mining-корутины
|
||||
runBlocking { handlesLock.withLock { handles[handle.id] = handle } }
|
||||
}
|
||||
|
||||
/** [ConversationAware]: разговор закрыт — забываем handle. */
|
||||
override fun detachConversation(handle: ConversationHandle) {
|
||||
runBlocking { handlesLock.withLock { handles.remove(handle.id) } }
|
||||
}
|
||||
|
||||
private fun mineForConversation(conversationId: String) {
|
||||
// горячий путь: одна mining-корутина читает snapshot handle и идёт работать.
|
||||
// блокировка короткая, copy-on-read не нужно — handle'ы immutable в нашей модели.
|
||||
val handle = runBlocking { handlesLock.withLock { handles[conversationId] } } ?: return
|
||||
if (handle.isTemporal) return
|
||||
scope.launch {
|
||||
val miner = SkillMiner(llm)
|
||||
val turns = runCatching { handle.recentTurns(miner.maxTurns) }.getOrDefault(emptyList())
|
||||
if (turns.isEmpty()) return@launch
|
||||
runCatching {
|
||||
// ConversationTurn (agent-api) == ConversationTurn (memory-api) через typealias.
|
||||
miner.mine(turns, store.catalog.skills)
|
||||
}
|
||||
.onSuccess { mined ->
|
||||
mined.forEach { runCatching { store.upsert(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SkillToolProvider(
|
||||
private val store: SkillStore,
|
||||
) : ToolProvider {
|
||||
override fun getTools(conversationId: String): List<LiteTool> {
|
||||
val catalog: SkillCatalog = store.catalog
|
||||
return buildList {
|
||||
add(SkillSaveTool(store))
|
||||
add(SkillDeleteTool(store))
|
||||
if (!catalog.isEmpty) add(SkillReadTool(catalog))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SkillSystemProvider(
|
||||
private val store: SkillStore,
|
||||
) : SystemPromptProvider {
|
||||
override fun getSection(conversationId: String): String =
|
||||
store.catalog.renderSystemPromptSection()
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
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\": []}.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Встроенная тула `read_skill`: отдаёт полный текст скила по имени.
|
||||
*
|
||||
* В системный промпт попадают только имя и краткое описание скилов
|
||||
* (см. `SkillCatalog.renderSystemPromptSection()`); этот тул загружает `body`
|
||||
* по требованию модели. Если имя неизвестно — возвращаем список доступных
|
||||
* имён, чтобы модель могла исправиться со следующей попытки.
|
||||
*/
|
||||
class SkillReadTool(
|
||||
private val catalog: SkillCatalog,
|
||||
) : LiteTool {
|
||||
|
||||
override val name: String get() = NAME
|
||||
|
||||
override fun describe(): String = buildJsonObject {
|
||||
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который
|
||||
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
|
||||
// OpenAI-формат {"type":"function","function":{...}}.
|
||||
put("name", NAME)
|
||||
put(
|
||||
"description",
|
||||
"Load the full text of a skill by its name. " +
|
||||
"Use it when the task matches one of the skills listed in the system prompt. " +
|
||||
"Always read a skill before following its instructions.",
|
||||
)
|
||||
put("parameters", buildJsonObject {
|
||||
put("type", "object")
|
||||
put("properties", buildJsonObject {
|
||||
put("name", buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "Skill name exactly as listed in the system prompt.")
|
||||
})
|
||||
})
|
||||
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name"))))
|
||||
})
|
||||
}.toString()
|
||||
|
||||
override fun invoke(arguments: String): String {
|
||||
val name = parseName(arguments)
|
||||
?: return "[tool error] read_skill: missing required argument \"name\""
|
||||
|
||||
val skill = catalog.find(name)
|
||||
?: return "[tool error] unknown skill \"$name\". Available skills: " +
|
||||
catalog.skills.joinToString(", ") { it.name }.ifEmpty { "<none>" }
|
||||
|
||||
return if (skill.body.isBlank()) {
|
||||
"(skill \"$name\" has an empty body)"
|
||||
} else {
|
||||
skill.body
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseName(arguments: String): String? {
|
||||
val raw = arguments.trim()
|
||||
if (raw.isEmpty()) return null
|
||||
val obj: JsonObject = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
|
||||
return (obj["name"] as? JsonPrimitive)?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Имя тула, как его видит модель. */
|
||||
const val NAME: String = "read_skill"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
import pw.binom.agentik.skills.SkillStore
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
* Тул `skill_save(name, description, body)` — сохраняет скил через [SkillStore].
|
||||
*
|
||||
* Схема аргументов (function-calling JSON):
|
||||
* ```
|
||||
* {
|
||||
* "name": "skill_save",
|
||||
* "description": "Создать или обновить скил. Имя может содержать двоеточия (как в opencode: 'backend:spring:db-base').",
|
||||
* "parameters": {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "name": {"type": "string", "description": "Имя скила, уникальное в каталоге."},
|
||||
* "description": {"type": "string", "description": "Одно-два предложения: когда применять."},
|
||||
* "body": {"type": "string", "description": "Markdown body скила."}
|
||||
* },
|
||||
* "required": ["name", "description", "body"]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class SkillSaveTool(
|
||||
private val store: SkillStore,
|
||||
) : LiteTool {
|
||||
override val name: String = "skill_save"
|
||||
override fun describe(): String = SCHEMA
|
||||
|
||||
override fun invoke(arguments: String): String {
|
||||
val parsed = parseArgs(arguments)
|
||||
?: throw IllegalStateException("invalid arguments: $arguments")
|
||||
val (name, description, body) = parsed
|
||||
return try {
|
||||
store.upsert(SkillFile(name = name, description = description, body = body))
|
||||
"""{"ok":true,"name":"${escape(name)}"}"""
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException(e.message ?: "upsert failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseArgs(arguments: String): Triple<String, String, String>? {
|
||||
val name = extractString(arguments, "name") ?: return null
|
||||
val description = extractString(arguments, "description") ?: return null
|
||||
val body = extractString(arguments, "body") ?: return null
|
||||
return Triple(name, description, body)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SCHEMA = """
|
||||
{
|
||||
"name": "skill_save",
|
||||
"description": "Создать или обновить скил. Имя может содержать двоеточия (например 'backend:spring:db-base'). Скил станет доступен в этом и следующих сеансах через read_skill.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"body": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "description", "body"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Тул `skill_delete(name)` — архивирует скил.
|
||||
*/
|
||||
class SkillDeleteTool(
|
||||
private val store: SkillStore,
|
||||
) : LiteTool {
|
||||
override val name: String = "skill_delete"
|
||||
override fun describe(): String = SCHEMA
|
||||
override fun invoke(arguments: String): String {
|
||||
val name = extractString(arguments, "name")
|
||||
?: throw IllegalStateException("missing 'name' in arguments: $arguments")
|
||||
return try {
|
||||
val removed = store.remove(name)
|
||||
if (removed) {
|
||||
"""{"ok":true,"archived":"${escape(name)}"}"""
|
||||
} else {
|
||||
throw IllegalStateException("skill '$name' not found")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException(e.message ?: "delete failed")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SCHEMA = """
|
||||
{
|
||||
"name": "skill_delete",
|
||||
"description": "Архивировать скил по имени. Скил больше не будет появляться в read_skill, но файл остаётся на диске с суффиксом .archived.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
|
||||
* JSON простой (плоский объект с известным набором ключей).
|
||||
*/
|
||||
internal fun extractString(json: String, key: String): String? {
|
||||
val keyIdx = json.indexOf("\"$key\"")
|
||||
if (keyIdx < 0) return null
|
||||
val colon = json.indexOf(':', keyIdx)
|
||||
if (colon < 0) return null
|
||||
val firstQuote = json.indexOf('"', colon)
|
||||
if (firstQuote < 0) return null
|
||||
|
||||
var i = firstQuote + 1
|
||||
val sb = StringBuilder()
|
||||
while (i < json.length) {
|
||||
val c = json[i]
|
||||
when {
|
||||
c == '\\' && i + 1 < json.length -> {
|
||||
when (val next = json[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
|
||||
}
|
||||
|
||||
/** JSON-escape строки. */
|
||||
internal fun escape(s: String): String = buildString(s.length + 2) {
|
||||
for (c in s) {
|
||||
when (c) {
|
||||
'"' -> append("\\\"")
|
||||
'\\' -> append("\\\\")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import pw.binom.litert.LiteContentPart
|
||||
import pw.binom.litert.LiteConversation
|
||||
import pw.binom.litert.LiteConversationConfig
|
||||
import pw.binom.litert.LiteDelta
|
||||
import pw.binom.litert.LiteLlm
|
||||
import pw.binom.litert.LiteMessage
|
||||
import pw.binom.litert.LiteRole
|
||||
import pw.binom.litert.LiteToolCall
|
||||
|
||||
/**
|
||||
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
|
||||
* заданной строкой [reply] двумя фрагментами + done.
|
||||
*/
|
||||
internal class FakeLiteLlm : LiteLlm {
|
||||
sealed class Reply {
|
||||
data class Text(val text: String) : Reply()
|
||||
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
|
||||
}
|
||||
|
||||
override val backendName: String = "fake"
|
||||
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||
var reply: String = ""
|
||||
var rememberHistory: Boolean = false
|
||||
var slow: Boolean = false
|
||||
var failMessage: String? = null
|
||||
|
||||
/**
|
||||
* Если задан, LLM проходит по этому списку ответов по порядку: первый
|
||||
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
|
||||
* кончился — fallback на [reply] (text).
|
||||
*/
|
||||
var scriptedReplies: MutableList<Reply> = mutableListOf()
|
||||
|
||||
var lastConfig: LiteConversationConfig? = null
|
||||
var lastContents: List<LiteContentPart>? = null
|
||||
val conversations = mutableListOf<FakeLiteConversation>()
|
||||
|
||||
override fun isInitialized(): Boolean = true
|
||||
|
||||
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
||||
lastConfig = config
|
||||
val conv = FakeLiteConversation(this, config)
|
||||
conversations.add(conv)
|
||||
return conv
|
||||
}
|
||||
|
||||
override fun infer(request: pw.binom.litert.LiteRequest): String =
|
||||
throw UnsupportedOperationException("not used in test")
|
||||
|
||||
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
|
||||
throw UnsupportedOperationException("not used in test")
|
||||
|
||||
override fun close() {}
|
||||
|
||||
fun nextReply(): Reply =
|
||||
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
|
||||
}
|
||||
|
||||
internal class FakeLiteConversation(
|
||||
private val parent: FakeLiteLlm,
|
||||
config: LiteConversationConfig,
|
||||
) : LiteConversation {
|
||||
val initialMessages: List<LiteMessage> = config.initialMessages
|
||||
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
||||
override val history: List<LiteMessage> get() = mutableHistory.toList()
|
||||
override var systemInstruction: String? = config.systemInstruction
|
||||
override var tools: List<pw.binom.litert.LiteTool> = config.tools
|
||||
|
||||
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
||||
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
||||
|
||||
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
||||
parent.lastContents = contents
|
||||
parent.failMessage?.let { msg ->
|
||||
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
|
||||
}
|
||||
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
||||
val next = parent.nextReply()
|
||||
return when (next) {
|
||||
is FakeLiteLlm.Reply.Text -> {
|
||||
if (parent.slow) {
|
||||
kotlinx.coroutines.flow.flow {
|
||||
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
|
||||
kotlinx.coroutines.delay(10_000)
|
||||
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
|
||||
mutableHistory.add(LiteMessage.model(next.text))
|
||||
}
|
||||
} else {
|
||||
val first = next.text.substring(0, next.text.length / 2)
|
||||
val second = next.text.substring(next.text.length / 2)
|
||||
flowOf(
|
||||
LiteDelta(text = first),
|
||||
LiteDelta(text = second, isDone = true),
|
||||
).also { mutableHistory.add(LiteMessage.model(next.text)) }
|
||||
}
|
||||
}
|
||||
is FakeLiteLlm.Reply.ToolCalls -> {
|
||||
val calls = next.calls.map { (name, args) ->
|
||||
LiteToolCall(name = name, arguments = args)
|
||||
}
|
||||
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun replaceHistory(newHistory: List<LiteMessage>) {
|
||||
mutableHistory.clear()
|
||||
mutableHistory.addAll(newHistory)
|
||||
}
|
||||
|
||||
override fun send(prompt: String): String {
|
||||
parent.lastContents = listOf(LiteContentPart.Text(prompt))
|
||||
return parent.reply
|
||||
}
|
||||
override fun sendContents(contents: List<LiteContentPart>): String {
|
||||
parent.lastContents = contents
|
||||
return parent.reply
|
||||
}
|
||||
override fun cancel() {}
|
||||
override fun tokenCount(): Int = history.size
|
||||
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
|
||||
LiteDelta(text = "", isDone = true)
|
||||
override fun close() {}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.agentik.memory.ConversationTurn
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.agentik.skill.mining.SkillMiner
|
||||
|
||||
class SkillMinerTest {
|
||||
|
||||
private fun turns(n: Int): List<ConversationTurn> = (1..n).map {
|
||||
ConversationTurn(userMessage = "q$it", assistantMessage = "a$it")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mine returns parsed skills from model JSON reply`() = runBlocking {
|
||||
val llm = FakeLiteLlm()
|
||||
llm.reply = """{"skills": [{"name": "n", "description": "d", "body": "b"}]}"""
|
||||
val miner = SkillMiner(llm, maxTurns = 10, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||
val out = miner.mine(turns(5), existing = emptyList())
|
||||
assertEquals(1, out.size)
|
||||
assertEquals("n", out[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mine with empty reply returns empty`() = runBlocking {
|
||||
val llm = FakeLiteLlm()
|
||||
llm.reply = "{}"
|
||||
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `miner failure degrades to empty list`() = runBlocking {
|
||||
val llm = FakeLiteLlm()
|
||||
llm.failMessage = "onnx died"
|
||||
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `miner respects maxTurns`() = runBlocking {
|
||||
val llm = FakeLiteLlm()
|
||||
llm.reply = """{"skills": []}"""
|
||||
val miner = SkillMiner(llm, maxTurns = 2, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||
miner.mine(turns(30), emptyList())
|
||||
val prompt = llm.lastContents!!.first().let {
|
||||
val p = it as pw.binom.litert.LiteContentPart.Text
|
||||
p.text
|
||||
}
|
||||
// В промпт попало только последние 2 хода из 30.
|
||||
assertTrue(prompt.contains("q29"), "last turns missing: $prompt")
|
||||
assertTrue(!prompt.contains("q1\n"), "old turn leaked: $prompt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty turns short-circuit without LLM call`() = runBlocking {
|
||||
val llm = FakeLiteLlm()
|
||||
llm.failMessage = "should not be called"
|
||||
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
|
||||
val out = miner.mine(emptyList(), emptyList())
|
||||
assertTrue(out.isEmpty())
|
||||
assertTrue(llm.conversations.isEmpty(), "LLM must not be called for empty input")
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import pw.binom.agentik.skill.mining.SkillMiningParser
|
||||
|
||||
class SkillMiningParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses clean JSON`() {
|
||||
val raw = """{"skills": [{"name": "backend:spring:db", "description": "x", "body": "# step 1"}]}"""
|
||||
val out = SkillMiningParser.parse(raw)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals("backend:spring:db", out[0].name)
|
||||
assertEquals("x", out[0].description)
|
||||
assertEquals("# step 1", out[0].body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses JSON wrapped in fences with prose around`() {
|
||||
val raw = "Окей, вот что я нашёл:\n```json\n" +
|
||||
"{\"skills\": [{\"name\": \"a\", \"description\": \"d\", \"body\": \"b\"}]}\n" +
|
||||
"```\nНадеюсь, помогло."
|
||||
val out = SkillMiningParser.parse(raw)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals("a", out[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses bare array without skills key`() {
|
||||
val raw = """[{"name": "x", "description": "d", "body": "b"}, {"name": "y"}]"""
|
||||
val out = SkillMiningParser.parse(raw)
|
||||
assertEquals(2, out.size)
|
||||
assertEquals("x", out[0].name)
|
||||
assertEquals("", out[1].body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unescapes newlines and quotes in body`() {
|
||||
val raw = "{\"skills\": [{\"name\": \"n\", \"description\": \"\", \"body\": \"line1\\nline2\\n\\nwith \\\"quotes\\\" and backslash \\\\\\\"\"}]}"
|
||||
val out = SkillMiningParser.parse(raw)
|
||||
assertEquals(1, out.size)
|
||||
val body = out[0].body
|
||||
assertTrue(body.contains("line1\nline2"), "body: $body")
|
||||
assertTrue(body.contains("\"quotes\""), "body: $body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty skills array returns empty list`() {
|
||||
val out = SkillMiningParser.parse("""{"skills": []}""")
|
||||
assertTrue(out.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `model chatter with no JSON returns empty`() {
|
||||
val out = SkillMiningParser.parse("Скилов не нашёл, всё чисто.")
|
||||
assertTrue(out.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated JSON returns empty`() {
|
||||
val out = SkillMiningParser.parse("""{"skills": [{"name": "a", "description": "d", "body": """"")
|
||||
assertTrue(out.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skills without name are dropped`() {
|
||||
val raw = """{"skills": [{"description": "no name"}, {"name": "ok"}]}"""
|
||||
val out = SkillMiningParser.parse(raw)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals("ok", out[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested braces inside strings do not break balance`() {
|
||||
val raw = """{"skills": [{"name": "n", "description": "d", "body": "echo '{\"k\": 1}'"}]}"""
|
||||
val out = SkillMiningParser.parse(raw)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals("echo '{\"k\": 1}'", out[0].body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package pw.binom.agentik.skill.mining
|
||||
|
||||
import pw.binom.agentik.skills.SkillCatalog
|
||||
import pw.binom.agentik.skills.SkillFile
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SkillReadToolTest {
|
||||
|
||||
private val catalog = SkillCatalog(
|
||||
listOf(
|
||||
SkillFile(name = "lint", description = "lint things", body = "# Lint\nRun the linter."),
|
||||
SkillFile(name = "empty", description = "no body", body = ""),
|
||||
),
|
||||
)
|
||||
private val tool = SkillReadTool(catalog)
|
||||
|
||||
@Test
|
||||
fun describeIsFlatOpenApiSchema() {
|
||||
val json = tool.describe()
|
||||
assertTrue("\"name\"" in json)
|
||||
assertTrue(SkillReadTool.NAME in json)
|
||||
assertTrue("parameters" in json)
|
||||
assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" in json))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeReturnsBodyForKnownSkill() {
|
||||
val result = tool.invoke("""{"name":"lint"}""")
|
||||
assertEquals("# Lint\nRun the linter.", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeUnknownSkillListsAvailable() {
|
||||
val result = tool.invoke("""{"name":"nope"}""")
|
||||
assertTrue("unknown skill" in result)
|
||||
assertTrue("lint" in result)
|
||||
assertTrue("empty" in result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeEmptyBodyGivesPlaceholder() {
|
||||
val result = tool.invoke("""{"name":"empty"}""")
|
||||
assertTrue("empty body" in result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeMissingNameIsError() {
|
||||
val result = tool.invoke("{}")
|
||||
assertTrue("[tool error]" in result)
|
||||
assertTrue("name" in result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeInvalidJsonIsError() {
|
||||
assertTrue("[tool error]" in tool.invoke("not json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invokeBlankArgumentsIsError() {
|
||||
assertTrue("[tool error]" in tool.invoke(""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user