Files
agentik/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetRegistry.kt
T
subochev 2e1387273a 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)
2026-09-15 14:55:51 +03:00

116 lines
5.3 KiB
Kotlin

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
}