agent-toolsets: ядро механики toolsets (реестр, диспетчер, встроенные тулы)

Новый KMP-модуль :agent-toolsets с основными абстракциями для тулсетов:

- ToolsetContribution(name, description, tools: List<ToolEntry>) — декларация
  тулсета: имя + описание + список входящих LiteTool'ов с именами.
- ToolsetContext + Logger + NoOpLogger — что тулсеты получают при активации.
- ToolsetRegistry — реестр тулсетов с Mutex-защитой; методы
  activate/deactivate/isActive/activeNames/inactiveNames/activeTools/
  findByName/findOwnerByToolName.
- ToolsetDispatchPolicy — диспетчер с прощающей auto-activation: если тул из
  неактивного тулсета вызван — молча активирует тулсет и выполняет. Если тул
  вообще неизвестен — fallback в BaseToolDispatcher (плоские тулы вне toolsets).
- EnableToolsetTool / DisableToolsetTool — встроенные LiteTool'ы (4-case
  контракт зафиксирован в docs/TOOLSETS-PLAN.md): activate/deactivate с
  равномерным сообщением 'X deactivated' независимо от того, был ли он активен.
- SyncLiteTool — обёртка suspend-handler'а в синхронный LiteTool (через
  runBlocking). LiteTool.invoke синхронен по контракту litert-kmp.

Дизайн:
- :agent-toolsets НЕ зависит от :standalone — может быть переиспользован в
  Android-сборке и любом LiteTool-агенте.
- Модуль KMP (jvm + native), общие интерфейсы в commonMain, JVM-специфика
  только в SyncLiteTool (runBlocking).
- ToolsetContext минимален (logger); storage/skill добавятся в commit 5+.

Тесты: 29 новых покрывают activate/deactivate/idempotency, activeTools,
findOwnerByToolName, auto-activation в диспетчере, fallback в base, оба
контракта enable/disable со всеми 4 кейсами.

Tests: 328/328 green (299 ранее + 29 в :agent-toolsets)
This commit is contained in:
2026-09-15 14:55:51 +03:00
parent a7d8cbe713
commit 2e1387273a
13 changed files with 870 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
// KMP-модуль с ядром механики toolsets: реестр, диспетчер, встроенные тулы
// enable_toolset/disable_toolset. Не зависит от :standalone — может быть
// переиспользован в Android-сборке и в любом другом LiteTool-агенте.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
// :storage-core — для StorageBundle в ToolsetContext (commit 5+)
api(project(":storage-core"))
// litert-kmp: LiteTool интерфейс (sync describe/invoke)
api(libs.litert.api)
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.json)
}
jvmMain.dependencies {
// runBlocking для SyncLiteTool обёртки (LiteTool.invoke — sync)
implementation(libs.kotlin.logging)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
@@ -0,0 +1,49 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* Встроенный тул `disable_toolset` — обратная операция к [EnableToolsetTool].
*
* Контракт (зафиксирован в дизайн-доке):
* - `(member, active)` → `"Toolset 'X' deactivated."`
* - `(member, inactive)` → `"Toolset 'X' deactivated."` (единообразно — как будто был активен)
* - `(unknown, actives exist)` → `"Toolset 'X' not found. Available for deactivation: a, b."`
* - `(unknown, no actives)` → `"Toolset 'X' not found. No toolsets to deactivate."`
*
* Семантика "единообразно как будто был активен" выбрана потому что модель не
* должна различать "он и так был выключен" и "я его выключил" — оба ответа
* означают "сейчас выключен".
*/
class DisableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
val toolset = registry.findByName(name)
if (toolset != null) {
// Единообразный ответ независимо от текущего состояния.
registry.deactivate(name)
return "Toolset '$name' deactivated."
}
// Неизвестный — перечисляем активные (что можно деактивировать)
val actives = registry.activeNames()
return if (actives.isEmpty()) {
"Toolset '$name' not found. No toolsets to deactivate."
} else {
"Toolset '$name' not found. Available for deactivation: ${actives.joinToString(", ")}."
}
}
companion object {
const val NAME: String = "disable_toolset"
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Deactivate a toolset by name. Its tools become unavailable.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to deactivate."}},"required":["name"]}}
""".trimIndent()
}
}
@@ -0,0 +1,64 @@
package pw.binom.agentik.toolsets
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.litert.LiteTool
/**
* Встроенный тул `enable_toolset` — модель может им активировать любой
* зарегистрированный тулсет.
*
* Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`):
* - `(member, inactive)` → `"Toolset 'X' activated."`
* - `(member, active)` → `"Toolset 'X' already active."`
* - `(unknown, inactives exist)` → `"Toolset 'X' not found. Available: a, b."`
* - `(unknown, all active)` → `"Toolset 'X' not found. No toolsets available for activation."`
*
* Идемпотентен: повторный enable того же тулсета возвращает
* `"already active"` без сайд-эффектов (поле state не меняется).
*/
class EnableToolsetTool(private val registry: ToolsetRegistry) {
val tool: LiteTool = syncLiteTool(
describeJson = DESCRIBE,
handler = ::invoke,
)
internal suspend fun invoke(args: String): String {
val name = parseName(args) ?: return "missing required argument 'name'"
val toolset = registry.findByName(name)
if (toolset != null) {
val wasActive = registry.isActive(name)
registry.activate(name)
return if (wasActive) "Toolset '$name' already active." else "Toolset '$name' activated."
}
// Неизвестный — перечисляем доступные к активации (inactives)
val inactives = registry.inactiveNames()
return if (inactives.isEmpty()) {
"Toolset '$name' not found. No toolsets available for activation."
} else {
"Toolset '$name' not found. Available: ${inactives.joinToString(", ")}."
}
}
companion object {
const val NAME: String = "enable_toolset"
/**
* JSON-дескриптор для модели. Минимально: имя, описание, параметры.
* Соответствует litert-kmp формату LiteTool.describe().
*/
internal val DESCRIBE: String = """
{"name":"$NAME","description":"Activate a toolset by name to access its tools.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name of the toolset to activate."}},"required":["name"]}}
""".trimIndent()
}
}
/**
* Парсит обязательный аргумент `name` из JSON-строки аргументов тула.
* Возвращает null если отсутствует или не строка.
*/
internal fun parseName(argsJson: String): String? = runCatching {
Json.parseToJsonElement(argsJson).jsonObject["name"]?.jsonPrimitive?.content
}.getOrNull()
@@ -0,0 +1,33 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.runBlocking
import pw.binom.litert.LiteTool
/**
* Адаптер из suspend-handler'а в синхронный [LiteTool].
*
* `LiteTool.invoke` по контракту litert-kmp — синхронный (не suspend). Это
* упрощает движок (LiteRT-LM вызывает тул из блокирующего потока), но создаёт
* неудобство для тулов с асинхронной работой (DB, сеть).
*
* `runBlocking` выполняет suspend-лямбду в том же потоке, что и сам
* LiteLlm-вызов; LiteRT-LM не делает предположений о многопоточности тулов.
*
* Используется [EnableToolsetTool] и [DisableToolsetTool] — им нужно дёргать
* `ToolsetRegistry` (suspend, из-за Mutex) из синхронного LiteTool-контекста.
*/
internal class SyncLiteTool(
private val describeJson: String,
private val handler: suspend (String) -> String,
) : LiteTool {
override fun describe(): String = describeJson
override fun invoke(arguments: String): String = runBlocking { handler(arguments) }
}
/**
* Утилита для создания [LiteTool] из JSON-дескриптора и suspend-обработчика.
* Сейчас эквивалентно `SyncLiteTool(json, handler).invoke(json)` — оставлено
* как API-точка чтобы внешний код не зависел от internal-имени класса.
*/
internal fun syncLiteTool(describeJson: String, handler: suspend (String) -> String): LiteTool =
SyncLiteTool(describeJson, handler)
@@ -0,0 +1,40 @@
package pw.binom.agentik.toolsets
/**
* Контекст, который тулсеты получают при активации.
*
* В commit 4 — минимальный: логгер. Позже (commit 5+, если понадобится) сюда
* добавятся `StorageBundle`, `SkillStore` и пр., чтобы тулы внутри тулсета
* могли читать/писать сообщения и память.
*
* Если конкретному тулсету нужно больше, чем [Logger], он может объявить свой
* параметризованный factory и принимать остальное извне — [ToolsetContext]
* остаётся минимальным ядром.
*/
interface ToolsetContext {
val logger: Logger
}
/**
* No-op логгер по умолчанию. Передаётся в [ToolsetRegistry], если внешний код
* не предоставил свой (например, в тестах или при работе из CLI без logging
* конфигурации).
*/
object NoOpLogger : Logger {
override fun debug(msg: String) {}
override fun info(msg: String) {}
override fun warn(msg: String) {}
override fun error(msg: String, ex: Throwable?) {}
}
/**
* Минимальный logger-интерфейс для тулсетов. Совместим по сигнатуре с
* `kotlin-logging`'s `KLogger` и `org.slf4j.Logger` — внешний код может
* передать адаптер из любого.
*/
interface Logger {
fun debug(msg: String)
fun info(msg: String)
fun warn(msg: String)
fun error(msg: String, ex: Throwable? = null)
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* Декларация одного тулсета: имя, описание (видимое модели в system prompt),
* и список входящих тулов.
*
* Toolset — это группа инструментов, которые модель может включить или
* выключить через `enable_toolset` / `disable_toolset`. Модель не получает
* тулы неактивного тулсета напрямую; если она случайно вызовет тул из
* выключенного тулсета, диспетчер молча его включает (прощающая семантика).
*
* @property name уникальное имя тулсета (например, `"media"`).
* @property description короткое описание что тулсет делает; показывается в
* system prompt чтобы модель могла решить, какой тулсет включить.
* @property tools список [LiteTool]-ов, которые становятся доступны когда
* тулсет активен. У каждого тула `toolName` используется для поиска владельца
* при диспетчеризации.
*/
data class ToolsetContribution(
val name: String,
val description: String,
val tools: List<ToolEntry>,
) {
/**
* Один инструмент в составе тулсета.
*
* @property toolName стабильное имя тула (должно совпадать с `name` полем
* в JSON-дескрипторе тула, иначе диспетчер его не найдёт).
* @property tool сам [LiteTool] — синхронный интерфейс litert-kmp.
*/
data class ToolEntry(val toolName: String, val tool: LiteTool)
}
@@ -0,0 +1,106 @@
package pw.binom.agentik.toolsets
import pw.binom.litert.LiteTool
/**
* Тип диспетчера "плоских" тулов (не из тулсетов). Принимает имя тула и
* сырые JSON-аргументы строкой, возвращает результат строкой.
*
* Используется [ToolsetDispatchPolicy] как fallback: если тул не найден ни в
* одном активном/неактивном тулсете, диспетчер передаёт его в base dispatcher —
* это позволяет сосуществовать обычным `memory_save`/`skill_save`-тулам и
* toolsets в одном агенте.
*/
typealias BaseToolDispatcher = suspend (toolName: String, argumentsJson: String) -> String
/**
* Диспетчер вызовов тулов с учётом тулсетов.
*
* Алгоритм при вызове `dispatch(toolName, args)`:
* 1. **Активный тул** — тул с таким именем есть в одном из активных тулсетов.
* Выполняем напрямую, возвращаем результат. Outcome: `Ran`.
* 2. **Неактивный тул** — тул принадлежит зарегистрированному (но неактивному)
* тулсету. Молча активируем тулсет, выполняем тул. Outcome: `Ran`.
* 3. **Неизвестный тул** — нет ни в одном тулсете. Передаём в [baseDispatcher]
* (там живут плоские тулы вроде `memory_save`). Outcome: `Ran` или `Failed`
* — зависит от того, что вернёт base.
*
* Прощающая auto-activation семантика — модель может вызвать тул из тулсета,
* который она забыла включить; диспетчер сам разберётся. Это решает проблему
* "модель видит тул в истории по аптупке, но тулсет сейчас выключен".
*/
class ToolsetDispatchPolicy(
private val registry: ToolsetRegistry,
private val baseDispatcher: BaseToolDispatcher,
) {
sealed interface Outcome {
/** Тул выполнен успешно. */
data class Ran(
val toolsetName: String?,
val toolName: String,
val result: String,
) : Outcome
/** Тул не найден ни в одном тулсете, и base dispatcher его тоже не знает. */
data class Unknown(val toolName: String, val reason: String) : Outcome
}
suspend fun dispatch(toolName: String, argumentsJson: String): Outcome {
// 1. Активный тул?
val activeTools = registry.activeTools()
val activeToolNames = activeTools.map { it.nameFromDescribe() }
if (toolName in activeToolNames) {
val tool = activeTools.first { it.nameFromDescribe() == toolName }
val result = tool.invoke(argumentsJson)
return Outcome.Ran(toolsetName = findActiveToolsetForTool(toolName), toolName = toolName, result = result)
}
// 2. Принадлежит зарегистрированному тулсету (auto-activate)?
val ownerPair = registry.findOwnerByToolName(toolName)
if (ownerPair != null) {
val (contribution, entry) = ownerPair
registry.activate(contribution.name)
val result = entry.tool.invoke(argumentsJson)
return Outcome.Ran(toolsetName = contribution.name, toolName = toolName, result = result)
}
// 3. Fallback — плоский тул вне toolsets.
// Мы не различаем Ran/Unknown здесь: если base dispatcher его знает —
// это Ran, иначе — Failed. Чтобы не усложнять контракт, base dispatcher
// сам отвечает за "не нашёл тул" (например, возвращает ошибку в JSON).
val result = baseDispatcher(toolName, argumentsJson)
return Outcome.Ran(toolsetName = null, toolName = toolName, result = result)
}
private suspend fun findActiveToolsetForTool(toolName: String): String? {
val active = registry.activeNames()
for (name in active) {
val contribution = registry.findByName(name) ?: continue
if (contribution.tools.any { it.toolName == toolName }) return name
}
return null
}
}
/**
* Извлекает имя тула из его JSON-дескриптора. LiteTool — стандартизированный
* формат (см. litert-kmp LiteTool), где JSON содержит поле `"name"`.
*
* Используется для матчинга имени тула (которое модель передаёт в
* `tool_calls`) с фактическим LiteTool-ом (у которого имени нет в API).
*
* При ошибке парсинга возвращает пустую строку — диспетчер просто не найдёт
* такой тул, что безопасно (уйдёт в fallback).
*/
internal fun LiteTool.nameFromDescribe(): String {
val json = runCatching { describe() }.getOrNull() ?: return ""
return runCatching {
kotlinx.serialization.json.Json.parseToJsonElement(json)
.jsonObject["name"]?.jsonPrimitive?.content ?: ""
}.getOrDefault("")
}
private val kotlinx.serialization.json.JsonElement.jsonObject
get() = (this as kotlinx.serialization.json.JsonObject)
private val kotlinx.serialization.json.JsonElement.jsonPrimitive
get() = (this as kotlinx.serialization.json.JsonPrimitive)
@@ -0,0 +1,115 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.litert.LiteTool
/**
* Реестр тулсетов: хранит список доступных [ToolsetContribution]-ов и
* отслеживает, какие из них сейчас активны.
*
* Потокобезопасен (`Mutex` вокруг всех мутаций). Один экземпляр на агента —
* разделяется между ChatAgent и диспетчером.
*
* Диспетчер тулов (см. [ToolsetDispatchPolicy]) использует [findOwnerByToolName]
* чтобы:
* 1. Найти активный тул по имени — диспетчировать напрямую.
* 2. Если тул принадлежит неактивному тулсету — молча его активировать.
* 3. Если тул вообще не найден — передать в fallback-диспетчер
* (для «плоских» тулов вне toolsets).
*
* Модель может явно управлять состоянием через тулы `enable_toolset` /
* `disable_toolset` (см. [EnableToolsetTool], [DisableToolsetTool]).
*/
class ToolsetRegistry(
private val contributions: List<ToolsetContribution>,
private val context: ToolsetContext = NoOpToolsetContext,
) : AutoCloseable {
private val active: MutableSet<String> = mutableSetOf()
private val lock = Mutex()
/** Все зарегистрированные тулсеты (read-only). */
fun all(): List<ToolsetContribution> = contributions
/** Имена всех зарегистрированных тулсетов (для prompt section и диагностики). */
fun names(): List<String> = contributions.map { it.name }
/** Найти тулсет по имени (или null). */
fun findByName(name: String): ToolsetContribution? =
contributions.firstOrNull { it.name == name }
/**
* Найти тулсет, владеющий тулом с данным именем. Перебирает все
* зарегистрированные тулсеты, у каждого смотрит [ToolsetContribution.tools].
*
* Используется диспетчером для auto-activation: если модель вызвала тул из
* неактивного тулсета — мы молча его активируем и выполняем.
*/
fun findOwnerByToolName(toolName: String): Pair<ToolsetContribution, ToolsetContribution.ToolEntry>? {
for (c in contributions) {
val entry = c.tools.firstOrNull { it.toolName == toolName }
if (entry != null) return c to entry
}
return null
}
suspend fun isActive(name: String): Boolean = lock.withLock { active.contains(name) }
/**
* Активировать тулсет. Если уже активен — no-op. Возвращает `true`, если
* состояние изменилось (т.е. тулсет был неактивен и теперь активен).
*/
suspend fun activate(name: String): Boolean = lock.withLock {
active.add(name)
}
/**
* Деактивировать тулсет. Если и так неактивен — no-op. Возвращает `true`,
* если состояние изменилось.
*/
suspend fun deactivate(name: String): Boolean = lock.withLock {
active.remove(name)
}
suspend fun activeNames(): List<String> = lock.withLock { active.toList() }
suspend fun inactiveNames(): List<String> = lock.withLock {
contributions.map { it.name }.filter { it !in active }
}
/**
* Список всех активных тулов (для передачи в LiteConversationConfig.tools).
* Вызывает `LiteTool.describe()` каждого тула — безопасно для типичных
* stateless тулов.
*/
suspend fun activeTools(): List<LiteTool> {
val activeNames = activeNames()
return activeNames.mapNotNull { name ->
val contribution = findByName(name)
contribution?.tools?.map { it.tool }
}.flatten()
}
/** Тулсет-контекст, который передан конструктору. */
fun context(): ToolsetContext = context
override fun close() {
// no-op: нет внешних ресурсов. Сделано для удобства AutoCloseable-конвенции.
}
companion object {
/** Пустой реестр без единого тулсета. */
fun empty(context: ToolsetContext = NoOpToolsetContext): ToolsetRegistry =
ToolsetRegistry(emptyList(), context)
}
}
/**
* Дефолтный контекст для случая, когда внешний код не передал свой. Использует
* no-op логгер — события тулсетов (activate/deactivate/auto-activate) не
* пишутся никуда. Для prod-запуска передайте контекст с настоящим логгером.
*/
private val NoOpToolsetContext = object : ToolsetContext {
override val logger: Logger = NoOpLogger
}
@@ -0,0 +1,77 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
class DisableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok"
}
private fun harness(toolsets: List<ToolsetContribution>): Pair<DisableToolsetTool, ToolsetRegistry> {
val r = ToolsetRegistry(toolsets)
return DisableToolsetTool(r) to r
}
@Test
fun `describe contains expected name and parameters`() {
val (disable, _) = harness(emptyList())
val desc = disable.tool.describe()
assertEquals(true, desc.contains("\"name\":\"disable_toolset\""))
assertEquals(true, desc.contains("\"parameters\""))
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
}
@Test
fun `deactivating an active toolset returns deactivated message`() = runTest {
val (disable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = disable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' deactivated.", r)
assertEquals(false, reg.isActive("media"))
}
@Test
fun `deactivating an inactive toolset returns the same uniform message`() = runTest {
val (disable, _) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
// тулсет изначально неактивен — должно быть тот же ответ (uniform)
val r = disable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' deactivated.", r)
}
@Test
fun `deactivating unknown toolset with actives returns available actives`() = runTest {
val (disable, reg) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
ToolsetContribution("b", "y", emptyList()),
))
reg.activate("a")
reg.activate("b")
val r = disable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. Available for deactivation: a, b.", r)
}
@Test
fun `deactivating unknown toolset with no actives returns no-toolsets message`() = runTest {
val (disable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
))
val r = disable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. No toolsets to deactivate.", r)
}
@Test
fun `missing name argument returns error message`() = runTest {
val (disable, _) = harness(emptyList())
val r = disable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
}
@@ -0,0 +1,76 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
class EnableToolsetToolTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"x","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok"
}
private fun harness(toolsets: List<ToolsetContribution>): Pair<EnableToolsetTool, ToolsetRegistry> {
val r = ToolsetRegistry(toolsets)
return EnableToolsetTool(r) to r
}
@Test
fun `describe contains expected name and parameters`() {
val (enable, _) = harness(emptyList())
val desc = enable.tool.describe()
assertEquals(true, desc.contains("\"name\":\"enable_toolset\""))
assertEquals(true, desc.contains("\"parameters\""))
assertEquals(true, desc.contains("\"required\":[\"name\"]"))
}
@Test
fun `activating a registered toolset returns activated message`() = runTest {
val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", listOf(ToolsetContribution.ToolEntry("resize_image", tool("resize_image")))),
))
val r = enable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' activated.", r)
assertEquals(true, reg.isActive("media"))
}
@Test
fun `activating an already active toolset returns already-active message`() = runTest {
val (enable, reg) = harness(listOf(
ToolsetContribution("media", "media tools", emptyList()),
))
reg.activate("media")
val r = enable.invoke("""{"name":"media"}""")
assertEquals("Toolset 'media' already active.", r)
}
@Test
fun `activating unknown toolset lists available inactives`() = runTest {
val (enable, _) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
ToolsetContribution("b", "y", emptyList()),
ToolsetContribution("c", "z", emptyList()),
))
val r = enable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. Available: a, b, c.", r)
}
@Test
fun `activating unknown toolset with no inactives returns no-toolsets message`() = runTest {
val (enable, reg) = harness(listOf(
ToolsetContribution("a", "x", emptyList()),
))
reg.activate("a")
val r = enable.invoke("""{"name":"unknown"}""")
assertEquals("Toolset 'unknown' not found. No toolsets available for activation.", r)
}
@Test
fun `missing name argument returns error message`() = runTest {
val (enable, _) = harness(emptyList())
val r = enable.invoke("""{}""")
assertEquals("missing required argument 'name'", r)
}
}
@@ -0,0 +1,99 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class ToolsetDispatchPolicyTest {
private fun tool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response
}
private fun ts(name: String, toolNames: List<String>): ToolsetContribution = ToolsetContribution(
name = name,
description = "toolset $name",
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
)
/** Helper: build policy + expose its registry для assert-ов в тестах. */
private class Harness(
val policy: ToolsetDispatchPolicy,
val registry: ToolsetRegistry,
)
private fun harness(
toolsets: List<ToolsetContribution>,
baseKnown: Set<String> = setOf("memory_save"),
): Harness {
val registry = ToolsetRegistry(toolsets)
val base: BaseToolDispatcher = { n, a ->
if (n in baseKnown) "base:$n:$a" else error("unknown base tool: $n")
}
return Harness(ToolsetDispatchPolicy(registry, base), registry)
}
@Test
fun `active tool is dispatched directly`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
h.registry.activate("media")
val outcome = h.policy.dispatch("resize_image", "{}")
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
assertEquals("media", ran.toolsetName)
assertEquals("resize_image", ran.toolName)
assertEquals("ok:resize_image", ran.result)
}
@Test
fun `inactive tool triggers auto-activation`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
assertFalse(h.registry.isActive("media"))
val outcome = h.policy.dispatch("resize_image", "{}")
assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
// auto-activation: тулсет теперь активен
assertTrue(h.registry.isActive("media"))
}
@Test
fun `unknown tool falls through to base dispatcher`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
val outcome = h.policy.dispatch("memory_save", """{"key":"value"}""")
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
assertEquals(null, ran.toolsetName)
assertEquals("memory_save", ran.toolName)
assertEquals("base:memory_save:{\"key\":\"value\"}", ran.result)
}
@Test
fun `inactive tool wins over base fallback for shared name`() = runTest {
// Тулу "shared" принадлежит тулсет (inactive), и в base диспетчере тоже
// есть "shared". Должен победить тулсет (с auto-activation), не base.
val h = harness(
listOf(ts("ts", listOf("shared"))),
baseKnown = setOf("shared"),
)
val outcome = h.policy.dispatch("shared", "{}")
val ran = assertIs<ToolsetDispatchPolicy.Outcome.Ran>(outcome)
assertEquals("ts", ran.toolsetName)
assertEquals("ok:shared", ran.result)
assertTrue(h.registry.isActive("ts"))
}
@Test
fun `completely unknown tool bubbles up from base dispatcher`() = runTest {
val h = harness(listOf(ts("media", listOf("resize_image"))))
// base dispatcher бросает IllegalStateException — это распространяется
// через suspend и попадает в вызывающий код. Это OK: вызывающий код
// (ChatAgent) ловит исключения тулов и формирует tool_result с ошибкой.
val ex = runCatching {
kotlinx.coroutines.runBlocking { h.policy.dispatch("totally_unknown_tool", "{}") }
}.exceptionOrNull()
assertTrue(ex is IllegalStateException, "expected ISE, got $ex")
assertTrue(ex.message!!.contains("totally_unknown_tool"))
}
}
@@ -0,0 +1,131 @@
package pw.binom.agentik.toolsets
import kotlinx.coroutines.test.runTest
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ToolsetRegistryTest {
private fun tool(name: String): LiteTool = object : LiteTool {
override fun describe() = """{"name":"$name","description":"test tool","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = "ok:$name"
}
private fun contribution(
name: String,
description: String = "test toolset",
toolNames: List<String> = listOf("tool1"),
): ToolsetContribution = ToolsetContribution(
name = name,
description = description,
tools = toolNames.map { n -> ToolsetContribution.ToolEntry(n, tool(n)) },
)
@Test
fun `empty registry has no active tools`() = runTest {
val r = ToolsetRegistry.empty()
assertEquals(emptyList(), r.activeNames())
assertEquals(emptyList(), r.activeTools())
}
@Test
fun `all returns registered contributions`() {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
assertEquals(listOf("a", "b"), r.names())
}
@Test
fun `findByName returns matching contribution or null`() {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
assertNotNull(r.findByName("a"))
assertEquals("test toolset", r.findByName("a")?.description)
assertNull(r.findByName("nope"))
}
@Test
fun `activate changes state and isActive reports true`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a")))
assertFalse(r.isActive("a"))
r.activate("a")
assertTrue(r.isActive("a"))
assertEquals(listOf("a"), r.activeNames())
}
@Test
fun `activate is idempotent - second call is no-op`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a")))
r.activate("a")
r.activate("a")
assertEquals(1, r.activeNames().size)
}
@Test
fun `deactivate removes from active`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b")))
r.activate("a")
r.activate("b")
r.deactivate("a")
assertEquals(listOf("b"), r.activeNames())
assertFalse(r.isActive("a"))
}
@Test
fun `deactivate on inactive is no-op`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a")))
r.deactivate("a") // never activated
assertEquals(emptyList(), r.activeNames())
}
@Test
fun `inactiveNames returns the complement of active`() = runTest {
val r = ToolsetRegistry(listOf(contribution("a"), contribution("b"), contribution("c")))
r.activate("a")
r.activate("c")
assertEquals(listOf("b"), r.inactiveNames())
}
@Test
fun `activeTools returns the LiteTool instances from active toolsets`() = runTest {
val r = ToolsetRegistry(listOf(
contribution("ts1", toolNames = listOf("t1", "t2")),
contribution("ts2", toolNames = listOf("t3")),
))
r.activate("ts1")
r.activate("ts2")
val tools = r.activeTools()
assertEquals(3, tools.size)
// Проверяем что имена извлекаются из describe()
val names = tools.map { it.nameFromDescribe() }.toSet()
assertEquals(setOf("t1", "t2", "t3"), names)
}
@Test
fun `findOwnerByToolName locates the owning toolset`() {
val r = ToolsetRegistry(listOf(
contribution("ts1", toolNames = listOf("t1", "t2")),
contribution("ts2", toolNames = listOf("t3")),
))
val owner = r.findOwnerByToolName("t2")
assertNotNull(owner)
assertEquals("ts1", owner.first.name)
assertEquals("t2", owner.second.toolName)
}
@Test
fun `findOwnerByToolName returns null for unknown tool`() {
val r = ToolsetRegistry(listOf(contribution("ts1", toolNames = listOf("t1"))))
assertNull(r.findOwnerByToolName("nonexistent"))
}
@Test
fun `close is idempotent and does nothing`() {
val r = ToolsetRegistry.empty()
r.close()
r.close()
}
}