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,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)
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,3 +58,7 @@ include(":storage-inmemory")
|
|||||||
// SqliteWorkingMemoryStore, SqliteReflectionStore. Бэкенд для прод-запуска
|
// SqliteWorkingMemoryStore, SqliteReflectionStore. Бэкенд для прод-запуска
|
||||||
// :standalone (путь к .db файлу в AGENTIK_DB).
|
// :standalone (путь к .db файлу в AGENTIK_DB).
|
||||||
include(":storage-sqlite")
|
include(":storage-sqlite")
|
||||||
|
// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные
|
||||||
|
// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть
|
||||||
|
// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+.
|
||||||
|
include(":agent-toolsets")
|
||||||
|
|||||||
Reference in New Issue
Block a user