Агент: механика скиллов и тулсетов (ядро 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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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-вызовы не пересекаются).
|
||||
*
|
||||
* Тулы: агент видит их в системном промпте (см. [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<ChatMessage>, system: String?) -> String,
|
||||
private val toolkit: AgentToolkit? = null,
|
||||
private val tools: List<AgentTool> = 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<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 "Ассистент не ответил (таймаут)"
|
||||
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: <name>`. */
|
||||
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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user