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
@@ -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
}