diff --git a/agent-toolsets/build.gradle.kts b/agent-toolsets/build.gradle.kts new file mode 100644 index 0000000..80c92a0 --- /dev/null +++ b/agent-toolsets/build.gradle.kts @@ -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) + } + } +} diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/DisableToolsetTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/DisableToolsetTool.kt new file mode 100644 index 0000000..7056bae --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/DisableToolsetTool.kt @@ -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() + } +} diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/EnableToolsetTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/EnableToolsetTool.kt new file mode 100644 index 0000000..e37f989 --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/EnableToolsetTool.kt @@ -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() diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SyncLiteTool.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SyncLiteTool.kt new file mode 100644 index 0000000..b593a08 --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SyncLiteTool.kt @@ -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) diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetContext.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetContext.kt new file mode 100644 index 0000000..af36424 --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetContext.kt @@ -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) +} diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetContribution.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetContribution.kt new file mode 100644 index 0000000..4e1ea3d --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetContribution.kt @@ -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, +) { + /** + * Один инструмент в составе тулсета. + * + * @property toolName стабильное имя тула (должно совпадать с `name` полем + * в JSON-дескрипторе тула, иначе диспетчер его не найдёт). + * @property tool сам [LiteTool] — синхронный интерфейс litert-kmp. + */ + data class ToolEntry(val toolName: String, val tool: LiteTool) +} diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicy.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicy.kt new file mode 100644 index 0000000..3f01e3a --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicy.kt @@ -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) diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetRegistry.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetRegistry.kt new file mode 100644 index 0000000..6cdc1e9 --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/ToolsetRegistry.kt @@ -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, + private val context: ToolsetContext = NoOpToolsetContext, +) : AutoCloseable { + + private val active: MutableSet = mutableSetOf() + private val lock = Mutex() + + /** Все зарегистрированные тулсеты (read-only). */ + fun all(): List = contributions + + /** Имена всех зарегистрированных тулсетов (для prompt section и диагностики). */ + fun names(): List = contributions.map { it.name } + + /** Найти тулсет по имени (или null). */ + fun findByName(name: String): ToolsetContribution? = + contributions.firstOrNull { it.name == name } + + /** + * Найти тулсет, владеющий тулом с данным именем. Перебирает все + * зарегистрированные тулсеты, у каждого смотрит [ToolsetContribution.tools]. + * + * Используется диспетчером для auto-activation: если модель вызвала тул из + * неактивного тулсета — мы молча его активируем и выполняем. + */ + fun findOwnerByToolName(toolName: String): Pair? { + 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 = lock.withLock { active.toList() } + + suspend fun inactiveNames(): List = lock.withLock { + contributions.map { it.name }.filter { it !in active } + } + + /** + * Список всех активных тулов (для передачи в LiteConversationConfig.tools). + * Вызывает `LiteTool.describe()` каждого тула — безопасно для типичных + * stateless тулов. + */ + suspend fun activeTools(): List { + 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 +} diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/DisableToolsetToolTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/DisableToolsetToolTest.kt new file mode 100644 index 0000000..e4f63bf --- /dev/null +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/DisableToolsetToolTest.kt @@ -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): Pair { + 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) + } +} diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/EnableToolsetToolTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/EnableToolsetToolTest.kt new file mode 100644 index 0000000..74ef744 --- /dev/null +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/EnableToolsetToolTest.kt @@ -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): Pair { + 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) + } +} diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicyTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicyTest.kt new file mode 100644 index 0000000..16dcfc3 --- /dev/null +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetDispatchPolicyTest.kt @@ -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): 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, + baseKnown: Set = 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(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(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(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(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")) + } +} diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetRegistryTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetRegistryTest.kt new file mode 100644 index 0000000..fe6b7fa --- /dev/null +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/ToolsetRegistryTest.kt @@ -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 = 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() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fbdff1e..9ab2649 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,3 +58,7 @@ include(":storage-inmemory") // SqliteWorkingMemoryStore, SqliteReflectionStore. Бэкенд для прод-запуска // :standalone (путь к .db файлу в AGENTIK_DB). include(":storage-sqlite") +// Ядро механики toolsets: ToolsetRegistry + ToolsetDispatchPolicy + встроенные +// тулы enable_toolset/disable_toolset. KMP, не зависит от :standalone, может быть +// переиспользован в Android-сборке. Интеграция с ChatAgent — commit 5+. +include(":agent-toolsets")