diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index c577428..f6d7162 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -24,10 +24,14 @@ import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttPhrase +import pw.binom.viewmate.phone.agent.AgentToolkit import pw.binom.viewmate.phone.agent.Assistant import pw.binom.viewmate.phone.agent.ChatDao import pw.binom.viewmate.phone.agent.ChatDb import pw.binom.viewmate.phone.agent.LlmClient +import pw.binom.viewmate.phone.agent.SkillRegistry +import pw.binom.viewmate.phone.agent.SkillRepository +import pw.binom.viewmate.phone.agent.ToolsetRegistry import pw.binom.viewmate.phone.stt.SttStreamer import pw.binom.viewmate.phone.stt.WhisperStt @@ -90,6 +94,7 @@ class PhoneApp : Application() { private var assistantLock = Any() private var assistant: Assistant? = null + private var toolkit: AgentToolkit? = null /** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */ private fun ensureAssistant(): Assistant? { @@ -97,8 +102,16 @@ class PhoneApp : Application() { assistant?.let { return it } if (BuildConfig.LLM_API_KEY.isBlank()) return null val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL) - return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) }) - .also { assistant = it } + val tk = toolkit ?: AgentToolkit( + skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()), + toolsets = ToolsetRegistry(), + showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } }, + ).also { toolkit = it } + return Assistant( + chatDao = chatDao, + chat = { history, system -> llm.chat(history, system) }, + toolkit = tk, + ).also { assistant = it } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentToolkit.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentToolkit.kt new file mode 100644 index 0000000..40d4c5a --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/AgentToolkit.kt @@ -0,0 +1,267 @@ +package pw.binom.viewmate.phone.agent + +import pw.binom.viewmate.core.log +import java.util.concurrent.ConcurrentHashMap + +/** + * Механика агента: ядро тулов + тулсеты + скиллы + сборка системного + * промпта. Чистая JVM-логика (без Android), юнит-тестируется. + * + * Три состояния промпта (пересобирается на каждый запрос): + * 1. Набор не активен: ядро + каталог тулсетов + индекс скиллов. + * 2. Активация: enable пересобирает промпт, сам вызов в историю НЕ пишется + * («системная тула-невидимка»). + * 3. Активен: ядро + описания тул набора + «Активные тулсеты: имя»; + * скилл, связанный с набором (имя скилла = имя тулсета), УБРАН из промпта. + * + * Бесшовная подмена: тул набора при НЕактивном наборе не выполняется вслепую — + * подменяется на enable_toolset|набор и модели явно сообщается, что вызов ещё + * НЕ выполнялся (иначе она выдумает результат). Если аргументы совпадают со + * схемой и исполнитель есть — вызов выполняется сразу, без второго круга. + */ +class AgentToolkit( + private val skills: SkillRegistry, + private val toolsets: ToolsetRegistry, + private val showText: ((String) -> Unit)? = null, +) { + + companion object { + const val TOOL_TIME = "get_current_time" + const val TOOL_SHOW_TEXT = "show_text" + const val TOOL_READ_SKILL = "read_skill" + const val TOOL_ENABLE = "enable_toolset" + const val TOOL_DISABLE = "disable_toolset" + + /** Ядро: 5 тул, всегда в промпте, компактно. */ + val CORE_TOOL_NAMES: List = + listOf(TOOL_TIME, TOOL_SHOW_TEXT, TOOL_READ_SKILL, TOOL_ENABLE, TOOL_DISABLE) + + private val CORE_LINES = listOf( + "get_current_time — текущие дата, время, часовой пояс (ISO 8601).", + "show_text|text — показать текст на очках.", + "read_skill|имя — прочитать полное содержимое скилла по имени.", + "enable_toolset|имя — активировать тулсет (живёт 10 мин от последнего вызова его тул).", + "disable_toolset|имя — деактивировать тулсет.", + ) + } + + /** Одноразовые напоминания по сессиям (невидимые тулы: «уже активен», «набора нет»). */ + private val pendingNotes = ConcurrentHashMap() + + // ---------- сборка промпта ---------- + + /** + * Системный промпт для сессии: ядро + каталог + активные тулсеты (с + * описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны). + * Просроченные тулсеты чистятся здесь же — фоновых таймеров нет. + */ + fun systemPrompt(sessionId: Long): String { + val note = pendingNotes.remove(sessionId) + val activeSets = toolsets.activeNames(sessionId) + val sb = StringBuilder() + sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ") + .append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.") + sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ") + .append("После получения результата тула ответь пользователю текстом.") + sb.append("\n\nЯдро:") + CORE_LINES.forEach { sb.append('\n').append(it) } + val catalog = toolsets.catalogLines() + if (catalog.isNotEmpty()) { + sb.append("\n\nКаталог тулсетов (активация — enable_toolset|имя):") + catalog.forEach { sb.append('\n').append(it) } + } + if (activeSets.isNotEmpty()) { + sb.append("\n\nАктивные тулсеты: ").append(activeSets.joinToString(", ")) + activeSets.forEach { name -> + toolsets.toolset(name)?.toolLines()?.forEach { sb.append('\n').append(it) } + } + } + val skillLines = skills.index(excluded = activeSets.toSet()) + if (skillLines.isNotEmpty()) { + sb.append("\n\nСкиллы (read_skill|имя):") + skillLines.forEach { sb.append('\n').append(it) } + } + if (note != null) sb.append("\n\nВажно: ").append(note) + return sb.toString() + } + + // ---------- парсинг аргументов ---------- + + /** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */ + fun schemaFor(tool: String): List = when (tool) { + TOOL_TIME -> emptyList() + TOOL_SHOW_TEXT -> listOf("text") + TOOL_READ_SKILL -> listOf("name") + TOOL_ENABLE, TOOL_DISABLE -> listOf("name") + else -> toolsets.signatureOf(tool)?.args ?: emptyList() + } + + /** + * Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для + * одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`, + * лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта. + */ + fun parseArgs(raw: String?, expected: List): Map { + val s = raw?.trim() + if (s.isNullOrEmpty()) return emptyMap() + val required = expected.filterNot { it.endsWith("?") }.distinct() + val out = LinkedHashMap() + var bareIndex = 0 + for (field in s.split(ALTER)) { + val f = field.trim() + if (f.isEmpty()) continue + val eq = f.indexOf('=') + if (eq > 0) { + val key = f.substring(0, eq).trim() + if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim() + } else if (bareIndex < required.size) { + // «Голое» значение (media_search|Название) идёт первому обязательному аргументу. + out[required[bareIndex++]] = f + } + } + return out + } + + private val ALTER = Regex("[,&]") + + // ---------- диспач вызовов ---------- + + /** + * Результат диспача: + * [Run] — результат пишется в историю (TOOL-сообщение); + * [Silent] — невидимая системная тула (enable/disable_toolset): в историю + * НЕ пишется, просто пересобирается промпт на следующем запросе. + */ + sealed interface Dispatch { + class Run(val result: String) : Dispatch + object Silent : Dispatch + } + + /** + * Диспач вызова тула: ядро + префиксная маршрутизация в тулсеты. + * Все тулы идемпотентные и прощающие; аргументы валидируются по реестру. + */ + fun dispatch(sessionId: Long, tool: String, args: Map): Dispatch { + when (tool) { + TOOL_TIME -> return Dispatch.Run(TimeTool.invoke("{}")) + + TOOL_SHOW_TEXT -> { + val text = args["text"] + if (text.isNullOrBlank()) { + return Dispatch.Run( + "show_text: аргументы неверны, не хватает \"text\". Схема: show_text|text=<текст>." + ) + } + if (showText == null) { + return Dispatch.Run("Текст \"$text\" не показан: отображение на очки не подключено.") + } + showText(text) + log("toolset", "show_text (сессия $sessionId): $text") + return Dispatch.Run("Показано на очках: $text") + } + + TOOL_READ_SKILL -> { + val name = args["name"] + if (name.isNullOrBlank()) { + return Dispatch.Run("read_skill: аргументы неверны, не хватает \"name\". Схема: read_skill|имя=<скилл>.") + } + val content = skills.readFull(name) + ?: return Dispatch.Run("Скилл \"$name\" не найден. " + skillsAvailable()) + log("toolset", "read_skill (сессия $sessionId): $name") + return Dispatch.Run(content) + } + + TOOL_ENABLE -> { + val name = args["name"] + if (name.isNullOrBlank()) { + return Dispatch.Run("enable_toolset: аргументы неверны, не хватает \"name\". Схема: enable_toolset|имя=<тулсет>.") + } + when (val r = toolsets.enable(sessionId, name)) { + is EnableResult.Activated -> + setNote(sessionId, if (r.alreadyActive) "Тулсет \"$name\" уже активен, таймаут продлён." else null) + is EnableResult.Unknown -> setNote(sessionId, r.message) + } + log("toolset", "enable_toolset (сессия $sessionId): $name") + return Dispatch.Silent + } + + TOOL_DISABLE -> { + val name = args["name"] + if (name.isNullOrBlank()) { + return Dispatch.Run("disable_toolset: аргументы неверны, не хватает \"name\". Схема: disable_toolset|имя=<тулсет>.") + } + when (val r = toolsets.disable(sessionId, name)) { + is DisableResult.Disabled -> setNote(sessionId, null) + is DisableResult.NotActive -> setNote(sessionId, r.message) + } + log("toolset", "disable_toolset (сессия $sessionId): $name") + return Dispatch.Silent + } + } + + // Префиксная маршрутизация: media_* → набор media. + val ts = toolsets.findByPrefix(tool) + ?: return Dispatch.Run("Тул \"$tool\" не найден. " + available()) + val sig = ts.signatureOf(tool) + ?: return Dispatch.Run( + "Тул \"$tool\" в тулсете \"${ts.name}\" нет. Тулы тулсета: " + + ts.toolLines().joinToString("; ") + "." + ) + + if (!toolsets.isActive(sessionId, ts.name)) { + // Бесшовная подмена: набор неактивен — активируем его (как будто + // вызван enable_toolset|набор) и явно говорим, что вызов ещё не выполнен. + toolsets.enable(sessionId, ts.name) + log("toolset", "подмена $tool → enable_toolset|${ts.name} (сессия $sessionId)") + val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() } + val executor = toolsets.executor(ts.name) + if (missing.isEmpty() && executor != null) { + val result = executor.execute(ts.name, tool, args) + toolsets.touch(sessionId, ts.name) + return Dispatch.Run(result) + } + return Dispatch.Run(substitutionMessage(ts, tool, sig)) + } + + val missing = sig.requiredArgs().filter { args[it].isNullOrBlank() } + if (missing.isNotEmpty()) { + val schema = buildString { + append(tool) + if (sig.args.isNotEmpty()) append('|').append(sig.args.joinToString(",")) + } + return Dispatch.Run( + "$tool: аргументы неверны, не хватает обязательных: ${missing.joinToString(", ")}. Схема: $schema." + ) + } + val executor = toolsets.executor(ts.name) + ?: return Dispatch.Run("Тулсет \"${ts.name}\" активен, но его тулы ещё не реализованы.") + val result = executor.execute(ts.name, tool, args) + toolsets.touch(sessionId, ts.name) + log("toolset", "$tool (сессия $sessionId) → $result") + return Dispatch.Run(result) + } + + /** Сообщение подмены: набор активирован + реестр + явная пометка «вызов ещё не выполнен». */ + private fun substitutionMessage(ts: Toolset, tool: String, sig: ToolSignature): String { + val registry = ts.toolLines().joinToString("; ") + val usage = when { + sig.args.isEmpty() -> "Для выполнения вызови $tool." + else -> "Для выполнения вызови $tool с аргументами: ${sig.argNames().joinToString(", ")}." + } + return "Тулсет ${ts.name} активирован. Реестр: $registry. " + + "Вызов $tool ещё НЕ выполнялся. $usage" + } + + private fun setNote(sessionId: Long, note: String?) { + if (note == null) pendingNotes.remove(sessionId) else pendingNotes[sessionId] = note + } + + private fun skillsAvailable(): String = + "Доступные скиллы: " + skills.names().joinToString(", ").ifBlank { "нет скиллов" } + + private fun available(): String { + val sets = toolsets.names() + return "Доступны: " + CORE_TOOL_NAMES.joinToString(", ") + + if (sets.isEmpty()) ". Тулсетов нет." else ". Тулсеты: " + sets.joinToString(", ") + "." + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt index 8b6e117..6988b54 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Assistant.kt @@ -8,15 +8,19 @@ import kotlinx.coroutines.withTimeoutOrNull * история накапливается в сессии. Синхронный вызов из одной корутины * (сериализация фраз — LLM-вызовы не пересекаются). * - * Тулы: агент видит их в системном промпте (см. [SYSTEM_PROMPT]); если ему - * нужна тула, он отвечает одной строкой `TOOL_CALL: <имя>` — ассистент - * выполняет тул и возвращает результат как tool-сообщение в истории, - * затем делает второй вызов LLM с результатом. Обычный текст (без маркера) + * Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если + * передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной + * строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально). + * Ассистент выполняет тул и возвращает результат как tool-сообщение в историю, + * затем делает повторный вызов LLM. Невидимые системные тулы + * (enable_toolset / disable_toolset) в историю НЕ пишутся — просто + * пересобирается системный промпт (состояние 3). Обычный текст (без маркера) * возвращается пользователю как финальный ответ. */ class Assistant( private val chatDao: ChatDao, private val chat: suspend (history: List, system: String?) -> String, + private val toolkit: AgentToolkit? = null, private val tools: List = AgentTools.ALL, private val timeoutMs: Long = 30_000, ) { @@ -42,33 +46,63 @@ class Assistant( val history = chatDao.messagesForSession(sessionId).takeLast(20) var answer = callLlm(history, sessionId) var lastToolResult: String? = null - // Цикл тулов: до 3 вызовов подряд (защита от зацикливания агента). - repeat(3) { - val toolName = parseToolCall(answer) ?: return answer - val tool = tools.firstOrNull { it.name == toolName } - if (tool == null) { - chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) - return "Тул $toolName не найден" + var visibleCalls = 0 + var llmCalls = 1 + // Цикл тулов: до [MAX_TOOL_CALLS] видимых вызовов подряд (защита от + // зацикливания агента). Невидимые (silent) вызовы лимит не съедают, + // но общее число LLM-вызовов ограничено [MAX_LLM_CALLS]. + while (true) { + val parsed = parseToolCall(answer) + ?: return answer // callLlm уже сохранил ASSISTANT-сообщение + val toolName = parsed.name + if (toolkit == null) { + // Наследственный путь (без механики тулсетов): простые тулы без аргументов. + val tool = tools.firstOrNull { it.name == toolName } + if (tool == null) { + chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) + return "Тул $toolName не найден" + } + lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" } + chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName) + logTool(sessionId, toolName, lastToolResult) + if (++visibleCalls >= MAX_TOOL_CALLS) break + answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) + llmCalls++ + continue + } + when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) { + is AgentToolkit.Dispatch.Run -> { + lastToolResult = result.result + chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName) + logTool(sessionId, toolName, result.result) + if (++visibleCalls >= MAX_TOOL_CALLS) break + answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) + llmCalls++ + } + + is AgentToolkit.Dispatch.Silent -> { + // Невидимая системная тула (enable/disable_toolset): вызов не + // пишется в историю, промпт пересобирается на следующем callLlm + // (переход в состояние 3 — «Активные тулсеты»). + logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)") + if (llmCalls++ >= MAX_LLM_CALLS) break + answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) + } } - val result = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" } - lastToolResult = result - chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result, toolCallId = toolName) - logTool(sessionId, toolName, result) - val withResult = chatDao.messagesForSession(sessionId).takeLast(20) - answer = callLlm(withResult, sessionId) } // LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ, // чтобы пользователь получил данные, а не сырой маркер. - val toolName = parseToolCall(answer) - return if (toolName != null && lastToolResult != null) { - "TOOL_CALL $toolName (повтор) — $lastToolResult" + val stillTool = parseToolCall(answer) + return if (stillTool != null && lastToolResult != null) { + "TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult" } else { answer } } private suspend fun callLlm(history: List, sessionId: Long): String { - val answer = withTimeoutOrNull(timeoutMs) { chat(history, systemPrompt()) } + val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt() + val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) } ?: return "Ассистент не ответил (таймаут)" if (!isToolCall(answer)) { chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) @@ -77,19 +111,34 @@ class Assistant( return answer } - /** Системный промпт: роль + описания тулов (если есть). */ - private fun systemPrompt(): String { + /** Системный промпт (наследственный путь, без [AgentToolkit]). */ + private fun legacySystemPrompt(): String { val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски." if (tools.isEmpty()) return base return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}" } companion object { - /** Простой протокол вызова тула: строка вида `TOOL_CALL: `. */ - private val TOOL_CALL_RE = Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)""", RegexOption.IGNORE_CASE) + /** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */ + const val MAX_TOOL_CALLS = 3 - private fun parseToolCall(answer: String): String? = - TOOL_CALL_RE.find(answer)?.groupValues?.get(1)?.lowercase() + /** Общий лимит LLM-вызовов на фразу (включая невидимые). */ + const val MAX_LLM_CALLS = 8 + + /** + * Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2` + * (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов). + */ + private val TOOL_CALL_RE = + Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE) + + data class ParsedToolCall(val name: String, val argsRaw: String?) + + private fun parseToolCall(answer: String): ParsedToolCall? { + val m = TOOL_CALL_RE.find(answer) ?: return null + val argsRaw = m.groupValues[2].trim().ifBlank { null } + return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw) + } private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Skills.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Skills.kt new file mode 100644 index 0000000..4d4f0ae --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Skills.kt @@ -0,0 +1,103 @@ +package pw.binom.viewmate.phone.agent + +import java.io.File + +/** + * Скилл = знание: «КАК пользоваться». Триггер (одна строка) + тело (markdown: + * шаги, команды, питфоллы). Пассивный: живёт в файлах, читается по требованию + * через tuл read_skill — содержимое в промпт НЕ попадает (ленивое чтение). + */ +data class Skill( + val name: String, + val trigger: String, + val body: String, +) + +/** + * Читает скиллы из папки `files/skills/` (внутренняя files-папка приложения). + * Формат файла: + * ``` + * --- + * name: media + * description: поиск фильмов, детали, запуск/управление просмотром. + * --- + * тело скилла (markdown)... + * ``` + * Без frontmatter — имя берётся из имени файла, всё содержимое = тело. + * Нетовой папки или пустой каталог — пустой список (механика работает на + * пустом каталоге; реальные скиллы кладутся позже). + */ +class SkillRepository(private val directory: File) { + private val extensions = setOf("md", "markdown", "txt") + + fun loadAll(): List { + val files = directory.listFiles { f -> f.isFile && f.extension.lowercase() in extensions } + ?: return emptyList() + return files + .sortedBy { it.name } + .mapNotNull { parse(it) } + .distinctBy { it.name } + } + + private fun parse(file: File): Skill? { + val text = runCatching { file.readText() }.getOrNull() ?: return null + val lines = text.lines() + var name = file.nameWithoutExtension + var trigger = "" + var body = text.trim() + if (lines.isNotEmpty() && lines[0].trim() == "---") { + // Фронтматтер: блок строками key: value между первой и второй строкой ---. + val close = lines.drop(1).indexOfFirst { it.trim() == "---" } + if (close >= 0) { + val fm = lines.subList(1, close + 1) + fmValue(fm, "name")?.let { name = it } + trigger = fmValue(fm, "description") ?: "" + body = lines.drop(close + 2).joinToString("\n").trim() + } + } + return Skill(name = name, trigger = trigger, body = body) + } + + /** Значение ключа из frontmatter (`key: значение`); пустое/отсутствующее — null. */ + private fun fmValue(fm: List, key: String): String? = + fm.firstOrNull { it.startsWith("$key:") } + ?.substringAfter(':') + ?.trim() + ?.ifBlank { null } +} + +/** + * Индекс скиллов (имя + триггер) для промпта; полное содержимое — только + * через [readFull] лениво (вызов read_skill). Индекс идёт в промпт; когда + * связанный тулсет активен, свой скилл (имя = имя тулсета) из индекса УБИРАЕТСЯ. + */ +class SkillRegistry(skills: List) { + private val byName: Map = skills.associateBy { it.name.lowercase() } + + /** Все имена (стабильный порядок). */ + fun names(): List = byName.values.map { it.name } + + fun has(name: String): Boolean = byName.containsKey(name.lowercase()) + + /** + * Строки индекса «имя — триггер» (одна строка на скилл). [excluded] — + * имена скиллов, которых в индексе не должно быть (например, скилл + * активного тулсета: искушение удалить, а не запретить). + */ + fun index(excluded: Set = emptySet()): List = + byName.values + .filter { !excluded.contains(it.name.lowercase()) } + .sortedBy { it.name } + .map { s -> s.trigger.takeIf { it.isNotBlank() }?.let { "${s.name} — $it" } ?: s.name } + + /** Полное содержимое скилла (триггер + тело) — результат read_skill. */ + fun readFull(name: String): String? = + byName[name.lowercase()]?.let { s -> + buildString { + append("Скилл \"").append(s.name).append("\"\n") + if (s.trigger.isNotBlank()) append("Триггер: ").append(s.trigger).append('\n') + append("\n") + append(s.body) + } + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Toolsets.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Toolsets.kt new file mode 100644 index 0000000..a706226 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/Toolsets.kt @@ -0,0 +1,191 @@ +package pw.binom.viewmate.phone.agent + +import java.util.concurrent.ConcurrentHashMap + +/** + * Тулсет = набор тул (активные руки, «ЧТО МОЖНО»). Все тулы набора несут + * префикс ИМЕНИ набора: media_search, media_info — префикс = маршрутизация + * (система видит media_* → знает набор и сигнатуры). + * + * Аргументы сигнатуры описываются ИМЕНАМИ: «query» — обязательный, + * «seek_ms?» — опциональный («?»). Описание тула в промпте одной строкой: + * имя + что делает + аргументы — компактно для маленькой модели. + */ +data class ToolSignature( + val name: String, + val description: String, + val args: List = emptyList(), +) { + /** Обязательные аргументы (без маркера «?»). */ + fun requiredArgs(): List = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') } + + /** Все имена аргументов (обязательные и опциональные, без маркера «?»). */ + fun argNames(): List = args.map { it.substringBefore('?') } +} + +/** + * Набор тул: имя (одновременно префикс), описание для каталога, список тул + * (сигнатуры БЕЗ префикса — префикс добавляется при маршрутизации). + */ +data class Toolset( + val name: String, + val description: String, + val tools: List, +) { + /** Полное имя тула (с префиксом): media + search → media_search. */ + fun toolFull(signature: ToolSignature): String = "${name}_${signature.name}" + + /** Найти сигнатуру по полному имени (media_search → search). */ + fun signatureOf(fullName: String): ToolSignature? = + tools.firstOrNull { toolFull(it) == fullName } + + /** Строка каталога: «media — управление медиа и воспроизведением (search/info/play)». */ + fun catalogLine(): String = "$name — $description" + + /** Строки описаний тул для промпта (секция «Активные тулсеты»). */ + fun toolLines(): List = tools.map { s -> + val argsPart = if (s.args.isEmpty()) "" else "|" + s.args.joinToString(",") + "${name}_${s.name}$argsPart — ${s.description}" + } +} + +/** Исполнитель тул набора: (набор, тул-имя (с префиксом), аргументы) → строка результата. */ +fun interface ToolsetExecutor { + fun execute(toolset: String, tool: String, args: Map): String +} + +/** Результат enable_toolset. */ +sealed class EnableResult { + /** Активен (при повторном вызове уже активен — таймаут продлён). */ + class Activated(val toolset: String, val alreadyActive: Boolean) : EnableResult() + /** Неизвестное имя: сообщение для модели («набора X нет, доступны: ...»). */ + class Unknown(val name: String, val message: String) : EnableResult() +} + +/** Результат disable_toolset — всегда не ошибка (идемпотентно и прощающе). */ +sealed class DisableResult { + class Disabled(val toolset: String) : DisableResult() + /** Набор не был активен — информационно, не ошибка. */ + class NotActive(val message: String) : DisableResult() +} + +/** + * Реестр тулсетов + активность НА СЕССИЮ (сессия → набор → deadline). + * + * - Не персистится: перезапуск приложения = всё сброшено. + * - Таймаут: [DEFAULT_TIMEOUT_MS] (10 минут) с последнего вызова тулы набора; + * enable и каждый успешный вызов [touch] продлевают. + * - Проверка ТОЛЬКО при сборке промпта / диспаче — никаких фоновых таймеров: + * просроченные записи чистятся лениво в [activeNames]/[isActive]. + * + * Часы и таймаут инжектируемые — юнит-тесты не ждут реальные 10 минут. + */ +class ToolsetRegistry( + val timeoutMs: Long = DEFAULT_TIMEOUT_MS, + private val clock: () -> Long = { System.currentTimeMillis() }, +) { + companion object { + /** Дефолтный таймаут активности: 10 минут. */ + const val DEFAULT_TIMEOUT_MS = 10 * 60_000L + } + + private val toolsets = LinkedHashMap() + private val executors = HashMap() + /** сессия → (набор → deadline, epoch-мс). Только в памяти. */ + private val active = ConcurrentHashMap>() + + /** Зарегистрировать набор (опционально с исполнителем его тул). */ + fun register(toolset: Toolset, executor: ToolsetExecutor? = null) { + synchronized(this) { + toolsets[toolset.name] = toolset + if (executor != null) executors[toolset.name] = executor + } + } + + /** Имена всех наборов каталога. */ + fun names(): List = synchronized(this) { toolsets.keys.toList() } + + fun toolset(name: String): Toolset? = synchronized(this) { toolsets[name] } + + /** + * Префиксная маршрутизация: имя тула с префиксом набора (media_search → media). + * Префикс = «имени_». + */ + fun findByPrefix(fullToolName: String): Toolset? = + synchronized(this) { + toolsets.entries.firstOrNull { (n, _) -> fullToolName.startsWith(n + "_") }?.value + } + + fun executor(name: String): ToolsetExecutor? = synchronized(this) { executors[name] } + + /** Строки каталога: «media — ...» (по одной строке на набор). */ + fun catalogLines(): List = synchronized(this) { toolsets.values.map { it.catalogLine() } } + + /** + * Активные наборы сессии (deadline > now). Просроченные записи чистятся + * здесь же (ленивая проверка, нет фона). Порядок = порядок каталога. + */ + fun activeNames(sessionId: Long): List = + synchronized(this) { + val session = active[sessionId] ?: return emptyList() + val now = clock() + val expired = session.entries.filter { it.value <= now }.map { it.key } + expired.forEach { session.remove(it) } + if (session.isEmpty()) active.remove(sessionId) + toolsets.keys.filter { session.containsKey(it) } + } + + /** Активен ли набор в сессии (с очисткой просроченных). */ + fun isActive(sessionId: Long, name: String): Boolean = activeNames(sessionId).contains(name) + + /** Сигнатура тула, если тул известного набора (для валидации аргументов). */ + fun signatureOf(fullToolName: String): ToolSignature? = + findByPrefix(fullToolName)?.signatureOf(fullToolName) + + /** + * Активировать набор в сессии (идемпотентно: повторный enable = продление + * таймаута, не ошибка). Deadline = now + timeout. + */ + fun enable(sessionId: Long, name: String): EnableResult { + val ts = toolset(name) ?: return EnableResult.Unknown( + name = name, + message = "Набора \"$name\" нет. " + when { + toolsets.isNotEmpty() -> "Доступны: " + names().joinToString(", ") + else -> "Доступных тулсетов нет." + }, + ) + synchronized(this) { + val session = active.computeIfAbsent(sessionId) { ConcurrentHashMap() } + val already = session.containsKey(ts.name) + session[ts.name] = clock() + timeoutMs + return EnableResult.Activated(ts.name, already) + } + } + + /** + * Снять набор в сессии (идемпотентно: «нет активных наборов» — не ошибка). + */ + fun disable(sessionId: Long, name: String): DisableResult { + synchronized(this) { + val session = active[sessionId] + if (session != null && session.remove(name) != null) return DisableResult.Disabled(name) + val anyActive = if (session != null && session.isNotEmpty()) { + val now = clock() + session.entries.removeAll { it.value <= now } + session.isNotEmpty() + } else { + false + } + val msg = if (anyActive) "Тулсет \"$name\" не активен." else "Нет активных тулсетов." + return DisableResult.NotActive(msg) + } + } + + /** Продлить таймаут (вызывается после успешного выполнения тулы набора). */ + fun touch(sessionId: Long, name: String) { + synchronized(this) { + val session = active[sessionId] ?: return + if (session.containsKey(name)) session[name] = clock() + timeoutMs + } + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/AgentToolkitTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/AgentToolkitTest.kt new file mode 100644 index 0000000..260d23e --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/AgentToolkitTest.kt @@ -0,0 +1,308 @@ +package pw.binom.viewmate.phone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Механика агента: три состояния промпта, бесшовная подмена, валидация + * аргументов, невидимые тулы, таймаут (инжект часов), по-сессийность. + */ +class AgentToolkitTest { + + private var now = 10_000_000L + + private class Fixture(val toolkit: AgentToolkit, val calls: MutableList, val shown: MutableList) + + private fun fixture(withExecutor: Boolean = true): Fixture { + now = 10_000_000L + val calls = mutableListOf() + val shown = mutableListOf() + val registry = ToolsetRegistry(timeoutMs = ToolsetRegistry.DEFAULT_TIMEOUT_MS, clock = { now }) + val ts = Toolset( + name = "media", + description = "Управление медиа и воспроизведением (search/info/play)", + tools = listOf( + ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")), + ToolSignature("info", "Детали фильма.", args = listOf("id")), + ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")), + ), + ) + if (withExecutor) { + registry.register(ts) { tset, tool, args -> + calls.add("$tset.$tool ${args.entries.joinToString { "${'{' }${it.key}=${it.value}${'}'}" }}") + "Результат $tool: ${args.entries.joinToString { "${it.key}=${it.value}" }}" + } + } else { + registry.register(ts, null) + } + val skills = SkillRegistry( + listOf( + Skill("media", "Поиск фильмов, детали, запуск/управление.", "ТЕЛО: media_search|query=..."), + Skill("web", "Как сделать веб-поиск.", "Шаги веб-поиска..."), + ), + ) + return Fixture( + toolkit = AgentToolkit(skills = skills, toolsets = registry, showText = { shown.add(it) }), + calls = calls, + shown = shown, + ) + } + + private fun run(d: AgentToolkit.Dispatch): String = + (d as AgentToolkit.Dispatch.Run).result + + // ---------- три состояния промпта ---------- + + @Test + fun `состояние 1 нет активных тулсетов, каталог и скиллы`() { + val p = fixture().toolkit.systemPrompt(1) + assertTrue(p.contains("Ядро:")) + assertTrue(p.contains("get_current_time")) + assertTrue(p.contains("show_text|text")) + assertTrue(p.contains("enable_toolset|имя")) + assertTrue(p.contains("Каталог тулсетов")) + assertTrue(p.contains("media — Управление медиа и воспроизведением")) + // скиллы: по строке на каждый + assertTrue(p.contains("media — Поиск фильмов, детали, запуск/управление.")) + assertTrue(p.contains("web — Как сделать веб-поиск.")) + // описания тул набора ещё не раскрыты (набор не активен) + assertFalse(p.contains("Активные тулсеты")) + assertFalse(p.contains("media_search|query")) + } + + @Test + fun `enable невидимый, промпт пересобирается, состояние 3`() { + val f = fixture() + val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + assertTrue(d is AgentToolkit.Dispatch.Silent) + val p = f.toolkit.systemPrompt(1) + assertTrue(p.contains("Активные тулсеты: media")) + assertTrue(p.contains("media_search|query — Поиск фильмов")) + assertTrue(p.contains("media_play|id,seek_ms? — Запуск/управление")) + // связанный скилл убран из промпта (имя скилла = имя тулсета) + assertFalse(p.contains("media — Поиск фильмов")) + // остальные скиллы на месте + assertTrue(p.contains("web — Как сделать веб-поиск.")) + } + + @Test + fun `повторный enable, напоминание, уже активен`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + val d2 = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + assertTrue(d2 is AgentToolkit.Dispatch.Silent) + val p1 = f.toolkit.systemPrompt(1) + assertTrue(p1.contains("Тулсет \"media\" уже активен, таймаут продлён")) + // напоминание одноразовое: следующий промпт без него + val p2 = f.toolkit.systemPrompt(1) + assertFalse(p2.contains("уже активен")) + } + + @Test + fun `disable обратно в состояние 1, двойной disable не ошибка`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media")) + val p1 = f.toolkit.systemPrompt(1) + assertFalse(p1.contains("Активные тулсеты")) + assertTrue(p1.contains("media — Поиск фильмов")) // скилл вернулся в индекс + val d2 = f.toolkit.dispatch(1, "disable_toolset", mapOf("name" to "media")) + assertTrue(d2 is AgentToolkit.Dispatch.Silent) + val p2 = f.toolkit.systemPrompt(1) + assertTrue(p2.contains("Нет активных тулсетов")) + } + + @Test + fun `неизвестный набор, напоминание с доступными в промпте`() { + val f = fixture() + val d = f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "other")) + assertTrue(d is AgentToolkit.Dispatch.Silent) + val p = f.toolkit.systemPrompt(1) + assertTrue(p.contains("Набора \"other\" нет")) + assertTrue(p.contains("media")) + } + + // ---------- бесшовная подмена ---------- + + @Test + fun `подмена, аргументы не совпадают, вызов явно не выполнялся`() { + val f = fixture() + val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap())) + assertTrue(msg.contains("Тулсет media активирован")) + assertTrue(msg.contains("Реестр:")) + assertTrue(msg.contains("media_search")) + assertTrue(msg.contains("ещё НЕ выполнялся")) + assertTrue(msg.contains("вызови media_search с аргументами: query")) + // тул не выполнялся реально + assertTrue(f.calls.isEmpty()) + // набор стал активен (подмена = enable) + assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media")) + } + + @Test + fun `подмена, валидные аргументы, выполнение без второго круга`() { + val f = fixture() + val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "Рассел"))) + assertTrue(msg.contains("Результат media_search")) + assertEquals(1, f.calls.size) + assertTrue(f.calls.single().contains("media.media_search")) + assertTrue(f.calls.single().contains("query=Рассел")) + assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media")) + } + + // ---------- валидация и маршрутизация ---------- + + @Test + fun `активный тулсет, не хватает аргументов, ошибка со схемой`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + val msg = run(f.toolkit.dispatch(1, "media_search", emptyMap())) + assertTrue(msg.contains("аргументы неверны")) + assertTrue(msg.contains("query")) + assertTrue(msg.contains("Схема: media_search|query")) + } + + @Test + fun `маршрутизация по префкису в набор, вызов идёт в executor`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + val msg = run(f.toolkit.dispatch(1, "media_info", mapOf("id" to "123"))) + assertTrue(msg.contains("Результат media_info")) + assertTrue(f.calls.single().contains("media.media_info")) + assertTrue(f.calls.single().contains("id=123")) + } + + @Test + fun `тул не входит в сигнатуры набора`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + val msg = run(f.toolkit.dispatch(1, "media_bogus", emptyMap())) + assertTrue(msg.contains("Тул \"media_bogus\" в тулсете \"media\" нет")) + } + + @Test + fun `неизвестный тул, ответ со списком доступных`() { + val msg = run(fixture().toolkit.dispatch(1, "frobo", emptyMap())) + assertTrue(msg.contains("Тул \"frobo\" не найден")) + assertTrue(msg.contains("enable_toolset")) + assertTrue(msg.contains("media")) + } + + @Test + fun `активный тулсет без исполнителя, понятное сообщение`() { + val f = fixture(withExecutor = false) + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + val msg = run(f.toolkit.dispatch(1, "media_search", mapOf("query" to "x"))) + assertTrue(msg.contains("ещё не реализованы")) + } + + // ---------- ядро тулов ---------- + + @Test + fun `read_skill, весь текст скилла`() { + val f = fixture() + val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "media"))) + assertTrue(msg.contains("ТЕЛО: media_search|query=...")) + } + + @Test + fun `read_skill неизвестный скилл, прощающая подсказка`() { + val f = fixture() + val msg = run(f.toolkit.dispatch(1, "read_skill", mapOf("name" to "неттакого"))) + assertTrue(msg.contains("Скилл \"неттакого\" не найден")) + assertTrue(msg.contains("media")) + assertTrue(msg.contains("web")) + } + + @Test + fun `show_text, текст шлётся в шир, результат и пропуск без дисплея`() { + val f = fixture() + val msg = run(f.toolkit.dispatch(1, "show_text", mapOf("text" to "Привет!"))) + assertEquals("Привет!", f.shown.single()) + assertTrue(msg.contains("Показано на очках: Привет!")) + val bad = run(f.toolkit.dispatch(1, "show_text", emptyMap())) + assertTrue(bad.contains("\"text\"")) + // без подключённого дисплея (showText = null) — понятный результат, не падение + val bare = AgentToolkit(SkillRegistry(emptyList()), ToolsetRegistry(clock = { now })) + val disconnected = run(bare.dispatch(1, "show_text", mapOf("text" to "X"))) + assertTrue(disconnected.contains("не показан")) + } + + @Test + fun `get_current_time, время в ISO`() { + val msg = run(fixture().toolkit.dispatch(1, "get_current_time", emptyMap())) + val ts = Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""") + assertTrue(ts.find(msg) != null, "не ISO 8601 в: $msg") + } + + // ---------- таймаут и сессии ---------- + + @Test + fun `истёкший тулсет исчезает из промпта, скилл возвращается`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media")) + // инжект часов: проскакиваем таймаут без реального ожидания 10 минут + now += ToolsetRegistry.DEFAULT_TIMEOUT_MS + 1_000L + val p = f.toolkit.systemPrompt(1) + assertFalse(p.contains("Активные тулсеты")) + assertFalse(p.contains("media_search|query")) + assertTrue(p.contains("media — Поиск фильмов")) // скилл снова в индексе + } + + @Test + fun `активность по сессиям, разные сессии независимы`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты")) + // в сессии 2 вызов тула неактивного набора — подмена активирует только её + val msg = run(f.toolkit.dispatch(2, "media_search", mapOf("query" to "X"))) + assertTrue(msg.contains("Результат media_search")) + assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media")) + assertTrue(f.toolkit.systemPrompt(2).contains("Активные тулсеты: media")) + } + + @Test + fun `disable в сессии 2 не трогает активный в сессии 1`() { + val f = fixture() + f.toolkit.dispatch(1, "enable_toolset", mapOf("name" to "media")) + f.toolkit.dispatch(2, "enable_toolset", mapOf("name" to "media")) + f.toolkit.dispatch(2, "disable_toolset", mapOf("name" to "media")) + assertTrue(f.toolkit.systemPrompt(1).contains("Активные тулсеты: media")) + assertFalse(f.toolkit.systemPrompt(2).contains("Активные тулсеты")) + } + + // ---------- парсинг аргументов ---------- + + @Test + fun `парсинг пар и голых значений, прощающий к лишнему`() { + val tk = fixture().toolkit + assertEquals(mapOf("name" to "media"), tk.parseArgs("media", listOf("name"))) + assertEquals(emptyMap(), tk.parseArgs(null, listOf("name"))) + assertEquals(emptyMap(), tk.parseArgs(" ", listOf("name"))) + assertEquals(mapOf("query" to "Расскл"), tk.parseArgs("query=Расскл", listOf("query"))) + assertEquals( + mapOf("id" to "12", "seek_ms" to "300"), + tk.parseArgs("id=12,seek_ms=300", listOf("id", "seek_ms?")), + ) + // «голое» значение — первому обязательному аргументу + assertEquals(mapOf("id" to "abc"), tk.parseArgs("abc", listOf("id", "seek_ms?"))) + // пар через «&» и лишние ключи — не ошибка + val m = tk.parseArgs("query=x&extra=5", listOf("query")) + assertEquals("x", m["query"]) + assertTrue(m.containsKey("extra")) + } + + @Test + fun `схема аргументов, schemaFor, у тулов`() { + val tk = fixture().toolkit + assertEquals(emptyList(), tk.schemaFor("get_current_time")) + assertEquals(listOf("text"), tk.schemaFor("show_text")) + assertEquals(listOf("name"), tk.schemaFor("enable_toolset")) + assertEquals(listOf("name"), tk.schemaFor("read_skill")) + assertEquals(listOf("id", "seek_ms?"), tk.schemaFor("media_play")) + assertEquals(emptyList(), tk.schemaFor("unknown_tool")) + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/SkillsTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/SkillsTest.kt new file mode 100644 index 0000000..48df000 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/SkillsTest.kt @@ -0,0 +1,101 @@ +package pw.binom.viewmate.phone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.io.File + +// Скиллы: реестр (JVM, без Android) — файлы skills/*.md в приложении. +class SkillsTest { + + private fun freshDir(): File = + File.createTempFile("vw-skills", "").also { it.delete(); it.mkdirs() } + + private fun write(dir: File, fileName: String, content: String) { + File(dir, fileName).writeText(content) + } + + @Test + fun `файл с frontmatter парсится в скилл`() { + val dir = freshDir() + write(dir, "media.md", """ + --- + name: media + --- + Вызови enable_toolset|media и дождись реестра тул. + 1. media_search|query=<название> + """.trimIndent()) + val skills = SkillRepository(dir).loadAll() + assertEquals(1, skills.size) + val s = skills.single() + assertEquals("media", s.name) + assertTrue(s.body.contains("enable_toolset|media")) + // frontmatter (заголовок) не должен попасть в тело + assertFalse(s.body.startsWith("---")) + } + + @Test + fun `description в frontmatter становится подсказкой (триггером)`() { + val dir = freshDir() + write(dir, "web.md", """ + --- + name: web + description: Как сделать веб-поиск. + --- + Шаги поиска... + """.trimIndent()) + val s = SkillRepository(dir).loadAll().single() + assertEquals("web", s.name) + assertEquals("Как сделать веб-поиск.", s.trigger) + assertTrue(s.body.contains("Шаги поиска")) + } + + @Test + fun `файл без frontmatter берёт имя из имени файла`() { + val dir = freshDir() + write(dir, "tips.md", "Советы: запускать воспроизведение сразу после буферизации.") + val s = SkillRepository(dir).loadAll().single() + assertEquals("tips", s.name) + assertEquals("Советы: запускать воспроизведение сразу после буферизации.", s.body) + assertEquals("", s.trigger) + } + + @Test + fun `пустой каталог и несуществующий каталог — пустой список`() { + val empty = freshDir() + assertTrue(SkillRepository(empty).loadAll().isEmpty()) + assertTrue(SkillRepository(File("no-such-skills-dir")).loadAll().isEmpty()) + } + + @Test + fun `реестр строки индекса и имена`() { + val reg = SkillRegistry( + listOf( + Skill("media", "Поиск фильмов, детали, запуск/управление.", "тело1"), + Skill("web", "Как сделать веб-поиск.", "тело2"), + ), + ) + assertEquals(listOf("media", "web"), reg.names()) + val lines = reg.index() + assertEquals(2, lines.size) + assertTrue(lines.any { it.startsWith("media — ") }) + assertTrue(lines.any { it.startsWith("web — ") }) + assertTrue(reg.index(excluded = setOf("media")).all { !it.startsWith("media") }) + assertTrue(reg.index(excluded = emptySet()) == lines) + } + + @Test + fun `readFull полный текст, неизвестный скилл — null и has`() { + val reg = SkillRegistry(listOf(Skill("media", "триггер", "текст_тела"))) + val full = reg.readFull("media") + assertNotNull(full) + assertTrue(full.contains("триггер")) + assertTrue(full.contains("текст_тела")) + assertNull(reg.readFull("other")) + assertTrue(reg.has("media")) + assertFalse(reg.has("other")) + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/ToolsetRegistryTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/ToolsetRegistryTest.kt new file mode 100644 index 0000000..a14ac51 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/ToolsetRegistryTest.kt @@ -0,0 +1,145 @@ +package pw.binom.viewmate.phone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Реестр тулсетов: активность на сессию, таймаут (инжект часов — без ожидания + * реальных 10 минут), идемпотентность enable/disable, префиксная маршрутизация. + */ +class ToolsetRegistryTest { + + private var now = 1_000_000L + + private fun newRegistry(timeoutMs: Long = ToolsetRegistry.DEFAULT_TIMEOUT_MS): ToolsetRegistry { + now = 1_000_000L + return ToolsetRegistry(timeoutMs = timeoutMs, clock = { now }) + } + + private fun media(): Toolset = Toolset( + name = "media", + description = "Управление медиа и воспроизведением (search/info/play)", + tools = listOf( + ToolSignature("search", "Поиск фильмов (возвращает id, название, год).", args = listOf("query")), + ToolSignature("info", "Детали фильма.", args = listOf("id")), + ToolSignature("play", "Запуск/управление воспроизведением.", args = listOf("id", "seek_ms?")), + ), + ) + + @Test + fun `enable активирует набор, активность на сессию`() { + val r = newRegistry() + r.register(media()) + val result = r.enable(1, "media") + assertTrue(result is EnableResult.Activated) + assertEquals(listOf("media"), r.activeNames(1)) + assertTrue(r.isActive(1, "media")) + // другая сессия — не активна: активность НЕ персистится и НЕ общее + assertFalse(r.isActive(2, "media")) + assertEquals(emptyList(), r.activeNames(2)) + } + + @Test + fun `повторный enable — продление, не ошибка`() { + val r = newRegistry(timeoutMs = 600_000) + r.register(media()) + r.enable(1, "media") + now += 9 * 60_000 + val result = r.enable(1, "media") as EnableResult.Activated + assertTrue(result.alreadyActive) + now += 9 * 60_000 // 18 минут суммарно, но от последнего вызова — 9 + assertTrue(r.isActive(1, "media")) + now += 600_001L + assertFalse(r.isActive(1, "media")) + } + + @Test + fun `истёкший таймаут чистится лениво`() { + val r = newRegistry(timeoutMs = 600_000) + r.register(media()) + r.enable(1, "media") + now += 600_001L + assertEquals(emptyList(), r.activeNames(1)) + assertFalse(r.isActive(1, "media")) + } + + @Test + fun `disable идемпотентен, не ошибка`() { + val r = newRegistry() + r.register(media()) + val first = r.disable(1, "media") as DisableResult.NotActive + assertTrue(first.message.contains("Нет активных тулсетов")) + r.enable(1, "media") + assertTrue(r.disable(1, "media") is DisableResult.Disabled) + val again = r.disable(1, "media") as DisableResult.NotActive + assertTrue(again.message.contains("Нет активных тулсетов")) + } + + @Test + fun `disable другого набора, когда активен свой — сообщение без «нет активных»`() { + val r = newRegistry() + r.register(media()) + r.register(Toolset("web", "Работа с вебом.", emptyList())) + r.enable(1, "web") + val res = r.disable(1, "media") as DisableResult.NotActive + assertTrue(res.message.contains("\"media\" не активен")) + // активный «web» затронут не был + assertTrue(r.isActive(1, "web")) + } + + @Test + fun `неизвестный набор — сообщение с доступными в ответе`() { + val r = newRegistry() + r.register(media()) + val unknown = r.enable(1, "other") as EnableResult.Unknown + assertEquals("other", unknown.name) + assertTrue(unknown.message.contains("\"other\"")) + assertTrue(unknown.message.contains("media")) + + val emptyReg = newRegistry() + val empty = emptyReg.enable(1, "other") as EnableResult.Unknown + assertTrue(empty.message.contains("Доступных тулсетов нет")) + } + + @Test + fun `маршрутизация по префкису и сигнатура по полному имени`() { + val r = newRegistry() + r.register(media()) + assertEquals("media", r.findByPrefix("media_search")?.name) + assertEquals("media", r.findByPrefix("media_play")?.name) + // без подчёркивания это имя набора, не тул + assertEquals(null, r.findByPrefix("media")) + assertEquals(null, r.findByPrefix("web_search")) + assertEquals("search", r.signatureOf("media_search")?.name) + assertEquals(listOf("id", "seek_ms?"), r.signatureOf("media_play")?.args) + assertEquals(null, r.signatureOf("media_bogus")) + } + + @Test + fun `touch продлевает таймаут на каждом вызове тулы набора`() { + val r = newRegistry(timeoutMs = 600_000) + r.register(media()) + r.enable(1, "media") + now += 9 * 60_000 + r.touch(1, "media") + now += 9 * 60_000 // от последнего touch — снова 9 минут + assertTrue(r.isActive(1, "media")) + now += 600_001L + assertFalse(r.isActive(1, "media")) + } + + @Test + fun `каталог и активные — независимы, строки каталога по одной на набор`() { + val r = newRegistry() + r.register(media()) + r.register(Toolset("web", "Работа с вебом.", emptyList())) + val catalog = r.catalogLines() + assertEquals(2, catalog.size) + assertTrue(catalog.any { it.startsWith("media — ") }) + assertTrue(catalog.any { it.startsWith("web — ") }) + // каталог есть, активных нет + assertEquals(emptyList(), r.activeNames(7)) + } +}