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:
@@ -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)
|
||||
}
|
||||
+106
@@ -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
|
||||
}
|
||||
+77
@@ -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)
|
||||
}
|
||||
}
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+99
@@ -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"))
|
||||
}
|
||||
}
|
||||
+131
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user