Агент: механика скиллов и тулсетов (ядро 5 тул, подмена, невидимые enable/disable)

По ТЗ TASK-agent-tools.md:
- Skills/SkillRepository/SkillRegistry: frontmatter-скиллы из files/skills, индекс для промпта, ленивое read_skill
- Toolsets/ToolsetRegistry: активность на сессию в хиппе, таймаут 10 мин (инжект часов), префиксная маршрутизация, идемпотентные enable/disable
- AgentToolkit: сборка промпта (3 состояния), диспач, бесшовная подмена вызова на активацию, валидация аргументов
- Assistant: цикл тулов с невидимыми вызовами (MAX_LLM_CALLS=8, MAX_TOOL_CALLS=3)
- Тесты: 18 новых (AgentToolkitTest 17 + SkillsTest 6 + ToolsetRegistryTest 9) — всего 120, зелёные
This commit is contained in:
2026-08-22 21:13:29 +03:00
parent 22369670cb
commit c5cbc893ac
8 changed files with 1206 additions and 29 deletions
@@ -24,10 +24,14 @@ import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase 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.Assistant
import pw.binom.viewmate.phone.agent.ChatDao import pw.binom.viewmate.phone.agent.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb import pw.binom.viewmate.phone.agent.ChatDb
import pw.binom.viewmate.phone.agent.LlmClient 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.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt import pw.binom.viewmate.phone.stt.WhisperStt
@@ -90,6 +94,7 @@ class PhoneApp : Application() {
private var assistantLock = Any() private var assistantLock = Any()
private var assistant: Assistant? = null private var assistant: Assistant? = null
private var toolkit: AgentToolkit? = null
/** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */ /** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */
private fun ensureAssistant(): Assistant? { private fun ensureAssistant(): Assistant? {
@@ -97,8 +102,16 @@ class PhoneApp : Application() {
assistant?.let { return it } assistant?.let { return it }
if (BuildConfig.LLM_API_KEY.isBlank()) return null if (BuildConfig.LLM_API_KEY.isBlank()) return null
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL) val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) }) val tk = toolkit ?: AgentToolkit(
.also { assistant = it } 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 }
} }
} }
@@ -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<String> =
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<Long, String>()
// ---------- сборка промпта ----------
/**
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
*/
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<String> = 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<String>): Map<String, String> {
val s = raw?.trim()
if (s.isNullOrEmpty()) return emptyMap()
val required = expected.filterNot { it.endsWith("?") }.distinct()
val out = LinkedHashMap<String, String>()
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<String, String>): 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(", ") + "."
}
}
@@ -8,15 +8,19 @@ import kotlinx.coroutines.withTimeoutOrNull
* история накапливается в сессии. Синхронный вызов из одной корутины * история накапливается в сессии. Синхронный вызов из одной корутины
* (сериализация фраз — LLM-вызовы не пересекаются). * (сериализация фраз — LLM-вызовы не пересекаются).
* *
* Тулы: агент видит их в системном промпте (см. [SYSTEM_PROMPT]); если ему * Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* нужна тула, он отвечает одной строкой `TOOL_CALL: <имя>` — ассистент * передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
* выполняет тул и возвращает результат как tool-сообщение в истории, * строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
* затем делает второй вызов LLM с результатом. Обычный текст (без маркера) * Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
* затем делает повторный вызов LLM. Невидимые системные тулы
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
* возвращается пользователю как финальный ответ. * возвращается пользователю как финальный ответ.
*/ */
class Assistant( class Assistant(
private val chatDao: ChatDao, private val chatDao: ChatDao,
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String, private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
private val toolkit: AgentToolkit? = null,
private val tools: List<AgentTool> = AgentTools.ALL, private val tools: List<AgentTool> = AgentTools.ALL,
private val timeoutMs: Long = 30_000, private val timeoutMs: Long = 30_000,
) { ) {
@@ -42,33 +46,63 @@ class Assistant(
val history = chatDao.messagesForSession(sessionId).takeLast(20) val history = chatDao.messagesForSession(sessionId).takeLast(20)
var answer = callLlm(history, sessionId) var answer = callLlm(history, sessionId)
var lastToolResult: String? = null var lastToolResult: String? = null
// Цикл тулов: до 3 вызовов подряд (защита от зацикливания агента). var visibleCalls = 0
repeat(3) { var llmCalls = 1
val toolName = parseToolCall(answer) ?: return answer // Цикл тулов: до [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 } val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) { if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
return "Тул $toolName не найден" return "Тул $toolName не найден"
} }
val result = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" } lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
lastToolResult = result chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result, toolCallId = toolName) logTool(sessionId, toolName, lastToolResult)
logTool(sessionId, toolName, result) if (++visibleCalls >= MAX_TOOL_CALLS) break
val withResult = chatDao.messagesForSession(sessionId).takeLast(20) answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
answer = callLlm(withResult, 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)
}
}
} }
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ, // LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
// чтобы пользователь получил данные, а не сырой маркер. // чтобы пользователь получил данные, а не сырой маркер.
val toolName = parseToolCall(answer) val stillTool = parseToolCall(answer)
return if (toolName != null && lastToolResult != null) { return if (stillTool != null && lastToolResult != null) {
"TOOL_CALL $toolName (повтор) — $lastToolResult" "TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
} else { } else {
answer answer
} }
} }
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String { private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
val answer = withTimeoutOrNull(timeoutMs) { chat(history, systemPrompt()) } val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
?: return "Ассистент не ответил (таймаут)" ?: return "Ассистент не ответил (таймаут)"
if (!isToolCall(answer)) { if (!isToolCall(answer)) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
@@ -77,19 +111,34 @@ class Assistant(
return answer return answer
} }
/** Системный промпт: роль + описания тулов (если есть). */ /** Системный промпт (наследственный путь, без [AgentToolkit]). */
private fun systemPrompt(): String { private fun legacySystemPrompt(): String {
val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски." val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски."
if (tools.isEmpty()) return base if (tools.isEmpty()) return base
return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}" return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}"
} }
companion object { companion object {
/** Простой протокол вызова тула: строка вида `TOOL_CALL: <name>`. */ /** Лимит видимых тул-вызовов подряд (повторные LLM-вызовы с результатом). */
private val TOOL_CALL_RE = Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)""", RegexOption.IGNORE_CASE) const val MAX_TOOL_CALLS = 3
private fun parseToolCall(answer: String): String? = /** Общий лимит LLM-вызовов на фразу (включая невидимые). */
TOOL_CALL_RE.find(answer)?.groupValues?.get(1)?.lowercase() 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 private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
@@ -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<Skill> {
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<String>, key: String): String? =
fm.firstOrNull { it.startsWith("$key:") }
?.substringAfter(':')
?.trim()
?.ifBlank { null }
}
/**
* Индекс скиллов (имя + триггер) для промпта; полное содержимое — только
* через [readFull] лениво (вызов read_skill). Индекс идёт в промпт; когда
* связанный тулсет активен, свой скилл (имя = имя тулсета) из индекса УБИРАЕТСЯ.
*/
class SkillRegistry(skills: List<Skill>) {
private val byName: Map<String, Skill> = skills.associateBy { it.name.lowercase() }
/** Все имена (стабильный порядок). */
fun names(): List<String> = byName.values.map { it.name }
fun has(name: String): Boolean = byName.containsKey(name.lowercase())
/**
* Строки индекса «имя — триггер» (одна строка на скилл). [excluded] —
* имена скиллов, которых в индексе не должно быть (например, скилл
* активного тулсета: искушение удалить, а не запретить).
*/
fun index(excluded: Set<String> = emptySet()): List<String> =
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)
}
}
}
@@ -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<String> = emptyList(),
) {
/** Обязательные аргументы (без маркера «?»). */
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
fun argNames(): List<String> = args.map { it.substringBefore('?') }
}
/**
* Набор тул: имя (одновременно префикс), описание для каталога, список тул
* (сигнатуры БЕЗ префикса — префикс добавляется при маршрутизации).
*/
data class Toolset(
val name: String,
val description: String,
val tools: List<ToolSignature>,
) {
/** Полное имя тула (с префиксом): 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<String> = 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, String>): 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<String, Toolset>()
private val executors = HashMap<String, ToolsetExecutor>()
/** сессия → (набор → deadline, epoch-мс). Только в памяти. */
private val active = ConcurrentHashMap<Long, ConcurrentHashMap<String, Long>>()
/** Зарегистрировать набор (опционально с исполнителем его тул). */
fun register(toolset: Toolset, executor: ToolsetExecutor? = null) {
synchronized(this) {
toolsets[toolset.name] = toolset
if (executor != null) executors[toolset.name] = executor
}
}
/** Имена всех наборов каталога. */
fun names(): List<String> = 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<String> = synchronized(this) { toolsets.values.map { it.catalogLine() } }
/**
* Активные наборы сессии (deadline > now). Просроченные записи чистятся
* здесь же (ленивая проверка, нет фона). Порядок = порядок каталога.
*/
fun activeNames(sessionId: Long): List<String> =
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
}
}
}
@@ -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<String>, val shown: MutableList<String>)
private fun fixture(withExecutor: Boolean = true): Fixture {
now = 10_000_000L
val calls = mutableListOf<String>()
val shown = mutableListOf<String>()
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<String, String>(), tk.parseArgs(null, listOf("name")))
assertEquals(emptyMap<String, String>(), 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"))
}
}
@@ -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"))
}
}
@@ -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))
}
}