Агент: механика скиллов и тулсетов (ядро 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
@@ -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))
}
}