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
|
||||
}
|
||||
Reference in New Issue
Block a user