From 1dc5552f986cae03c5b0b2c6a9bb938102b7466a Mon Sep 17 00:00:00 2001 From: subochev Date: Tue, 15 Sep 2026 15:06:23 +0300 Subject: [PATCH] =?UTF-8?q?agent-toolsets:=20SystemPromptToolsetSection=20?= =?UTF-8?q?+=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=20ChatAgent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен SystemPromptToolsetSection — рендер markdown-секции для system prompt. Контракт: - toolsets пустой → null (секция не добавляется, агент не знает о механике) - иначе → краткое описание концепции + список 'name — description' для активных и неактивных (одинаковый формат per design contract) - auto-activation НЕ упоминается в промпте (только в dispatch) Интеграция в ChatAgent: - Добавлен параметр toolsets: List = emptyList() - При пустом списке — enable_toolset/disable_toolset НЕ регистрируются, секция в system prompt НЕ появляется (полная невидимость per A1-α) - При непустом — тулы регистрируются, секция добавляется - ToolsetRegistry + ToolsetDispatchPolicy создаются per-agent (один реестр на все диалоги — состояние 'активные тулсеты' общее) Интеграция в ChatConversation: - Новый параметр toolsetDispatch: ToolsetDispatchPolicy? = null - runToolAndPersist: если задан — вызов идёт через policy (auto-activate неактивных тулсетов, fallback в base dispatcher для плоских тулов) - Иначе — старое поведение через toolsByName Тесты: - 7 новых в :agent-toolsets (SystemPromptToolsetSection): пустые списки, только активные, только неактивные, оба, проверка отсутствия auto-activation упоминания, registry-based рендер, пустой реестр - 5 новых в :standalone (ChatAgentToolsetsTest): default (пустой) — нет тулов и секции; non-empty — тулы и секция есть; enable_toolset активирует; вызов тула из неактивного тулсета — auto-activate; disable_toolset снимает из active set (но auto-activate на следующем вызове — by design) Tests: 340/340 green (335 ранее + 5 новых ChatAgent integration) --- .../toolsets/SystemPromptToolsetSection.kt | 54 +++++ .../SystemPromptToolsetSectionTest.kt | 87 ++++++++ standalone/build.gradle.kts | 1 + .../agentik/standalone/agent/ChatAgent.kt | 65 +++++- .../standalone/agent/ChatConversation.kt | 36 +++- .../standalone/agent/ChatAgentToolsetsTest.kt | 197 ++++++++++++++++++ 6 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSection.kt create mode 100644 agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSectionTest.kt create mode 100644 standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt diff --git a/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSection.kt b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSection.kt new file mode 100644 index 0000000..a23f85d --- /dev/null +++ b/agent-toolsets/src/commonMain/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSection.kt @@ -0,0 +1,54 @@ +package pw.binom.agentik.toolsets + +/** + * Markdown-секция для system prompt, описывающая доступные тулсеты. + * + * Контракт (зафиксирован в дизайн-доке `docs/TOOLSETS-PLAN.md`): + * - **Если toolsets пустой** → `null` (секция не добавляется, агент не знает + * о механике toolsets вообще; тулы enable_toolset/disable_toolset тоже + * не регистрируются — полная невидимость). + * - **Иначе** → короткое описание концепции + список всех тулсетов + * в формате `name — description`, активные и неактивные одинаково + * (модель видит за что каждый отвечает). + * + * Auto-activation НЕ упоминается в prompt — только в dispatch (если модель + * случайно вызвала тул из выключенного тулсета, диспетчер сам активирует). + * Это чтобы не давать модели ложную опцию "не буду enable, а просто вызову". + */ +object SystemPromptToolsetSection { + + fun render( + active: List, + inactive: List, + ): String? { + if (active.isEmpty() && inactive.isEmpty()) return null + return buildString { + appendLine("## Toolsets") + appendLine() + appendLine("Toolsets group related tools. Use enable_toolset to activate one; its tools become available. Use disable_toolset to deactivate.") + appendLine() + if (active.isNotEmpty()) { + appendLine("Active:") + for (c in active) appendLine("- ${c.name} — ${c.description}") + appendLine() + } + if (inactive.isNotEmpty()) { + appendLine("Inactive:") + for (c in inactive) appendLine("- ${c.name} — ${c.description}") + appendLine() + } + }.trim() + } + + /** + * Convenience: рендер по [ToolsetRegistry] (синхронный — без activeTools(), + * только имена и описания). + */ + fun render(registry: ToolsetRegistry, activeNames: Set): String? { + val all = registry.all() + if (all.isEmpty()) return null + val active = all.filter { it.name in activeNames } + val inactive = all.filter { it.name !in activeNames } + return render(active, inactive) + } +} diff --git a/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSectionTest.kt b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSectionTest.kt new file mode 100644 index 0000000..545672d --- /dev/null +++ b/agent-toolsets/src/commonTest/kotlin/pw/binom/agentik/toolsets/SystemPromptToolsetSectionTest.kt @@ -0,0 +1,87 @@ +package pw.binom.agentik.toolsets + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import pw.binom.litert.LiteTool + +class SystemPromptToolsetSectionTest { + + private fun stub(name: String, desc: String): ToolsetContribution = ToolsetContribution( + name = name, + description = desc, + tools = emptyList(), // prompt section не зависит от tools + ) + + @Test + fun `empty lists return null - section is omitted entirely`() { + assertNull(SystemPromptToolsetSection.render(emptyList(), emptyList())) + } + + @Test + fun `only active present - omits inactive header`() { + val section = SystemPromptToolsetSection.render( + active = listOf(stub("a", "first toolset")), + inactive = emptyList(), + ) + assertEquals(true, section!!.contains("## Toolsets")) + assertEquals(true, section.contains("Active:")) + assertEquals(true, section.contains("- a — first toolset")) + assertEquals(false, section.contains("Inactive:")) + } + + @Test + fun `only inactive present - omits active header`() { + val section = SystemPromptToolsetSection.render( + active = emptyList(), + inactive = listOf(stub("b", "second toolset")), + ) + assertEquals(true, section!!.contains("## Toolsets")) + assertEquals(true, section.contains("Inactive:")) + assertEquals(true, section.contains("- b — second toolset")) + assertEquals(false, section.contains("\nActive:")) + } + + @Test + fun `both active and inactive - renders both blocks`() { + val section = SystemPromptToolsetSection.render( + active = listOf(stub("a", "first")), + inactive = listOf(stub("b", "second"), stub("c", "third")), + ) + assertEquals(true, section!!.contains("- a — first")) + assertEquals(true, section.contains("- b — second")) + assertEquals(true, section.contains("- c — third")) + } + + @Test + fun `does not mention auto-activation - per design contract`() { + val section = SystemPromptToolsetSection.render( + active = emptyList(), + inactive = listOf(stub("a", "x")), + )!! + // Дизайн-док: auto-activation НЕ в промпте (только в dispatch) + assertEquals(false, section.contains("auto", ignoreCase = true)) + assertEquals(false, section.contains("автоматическ", ignoreCase = true)) + } + + @Test + fun `registry-based render filters by active names`() = runTest { + val reg = ToolsetRegistry(listOf( + stub("a", "first"), + stub("b", "second"), + )) + reg.activate("a") + val section = SystemPromptToolsetSection.render(reg, setOf("a")) + assertEquals(true, section!!.contains("- a — first")) + assertEquals(true, section.contains("- b — second")) + assertEquals(true, section.contains("Active:")) + assertEquals(true, section.contains("Inactive:")) + } + + @Test + fun `empty registry renders null`() = runTest { + val reg = ToolsetRegistry.empty() + assertNull(SystemPromptToolsetSection.render(reg, setOf())) + } +} diff --git a/standalone/build.gradle.kts b/standalone/build.gradle.kts index 500ee5a..da546ed 100644 --- a/standalone/build.gradle.kts +++ b/standalone/build.gradle.kts @@ -49,6 +49,7 @@ kotlin { implementation(project(":memory-vector")) implementation(project(":storage-core")) implementation(project(":storage-sqlite")) + implementation(project(":agent-toolsets")) // litert-google: встроенный LiteRT-LM движок, нужен только на runtime runtimeOnly(libs.litert.google) diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index 2ab5e9f..6d405ef 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -20,6 +20,12 @@ import pw.binom.agentik.storage.ConversationRecord import pw.binom.agentik.storage.Reflection import pw.binom.agentik.storage.WorkingMemoryEntry import pw.binom.agentik.storage.sqlite.SqliteStores +import pw.binom.agentik.toolsets.EnableToolsetTool +import pw.binom.agentik.toolsets.DisableToolsetTool +import pw.binom.agentik.toolsets.SystemPromptToolsetSection +import pw.binom.agentik.toolsets.ToolsetContribution +import pw.binom.agentik.toolsets.ToolsetDispatchPolicy +import pw.binom.agentik.toolsets.ToolsetRegistry import pw.binom.litert.LiteLlm import kotlin.time.Instant @@ -102,11 +108,31 @@ class ChatAgent( * Через сколько пользовательских ходов запускать skill mining. `0` = выключено. */ private val skillMiningInterval: Int = 0, + /** + * Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает + * о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются, + * секция в system prompt НЕ добавляется (полная невидимость per A1-α). + */ + private val toolsets: List = emptyList(), ) : ProtoAgent, AutoCloseable { + /** + * Реестр активных тулсетов — один на агента (per-agent state). + * `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations. + */ + private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets) + + /** Список активных тулов (включая enable/disable если есть тулсеты). */ + private val enabledToolsetTools: List = if (toolsets.isNotEmpty()) { + listOf( + NamedTool(EnableToolsetTool.NAME, EnableToolsetTool(toolsetRegistry).tool), + NamedTool(DisableToolsetTool.NAME, DisableToolsetTool(toolsetRegistry).tool), + ) + } else emptyList() + /** * Системный промпт: (soul, если задан) → база → секция навыков → секция памяти - * → секция self-reflection (слабые места). + * → секция self-reflection (слабые места) → секция тулсетов (если они заданы). * Именно он сидируется в working memory и передаётся в [ChatConversation]. */ private val systemPrompt: String = buildSystemPrompt( @@ -115,21 +141,46 @@ class ChatAgent( memoryEnabled = memoryStore != null, soulBody = soulBody, reflections = recentReflections, + toolsetSection = if (toolsets.isNotEmpty()) { + SystemPromptToolsetSection.render( + active = emptyList(), // все по умолчанию неактивны + inactive = toolsets, + ) + } else null, ) /** * Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные: * `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть - * [skillStore]), `memory_*` (если подключена память). + * [skillStore]), `memory_*` (если подключена память), + * `enable_toolset`/`disable_toolset` (если заданы [toolsets]). * MCP-тулы префиксованы `server__`, так что коллизий нет. */ private val allTools: List = buildList { addAll(tools) + addAll(enabledToolsetTools) if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills))) if (skillStore != null) addAll(SkillToolsFactory.create(skillStore)) if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore)) } + private val toolsByName: Map = allTools.associateBy { it.name } + + /** + * Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда + * [toolsets] пустой (тогда работает как passthrough через baseDispatcher). + * Это позволяет ChatConversation.runTurn всегда идти через один путь, + * без ветвления «с тулсетами / без». + */ + private val toolsetDispatch: ToolsetDispatchPolicy = ToolsetDispatchPolicy( + registry = toolsetRegistry, + baseDispatcher = { name, args -> + val t = toolsByName[name] + ?: error("unknown tool: $name") + t.tool.invoke(args) + }, + ) + private val agentEvents = MutableSharedFlow( extraBufferCapacity = 64, ) @@ -174,6 +225,7 @@ class ChatAgent( llm = llm, systemPrompt = systemPrompt, tools = allTools, + toolsetDispatch = toolsetDispatch, memoryPrefetcher = memoryPrefetcher, memoryReviewer = memoryReviewer, memoryStoreForReview = memoryStore, @@ -224,6 +276,7 @@ class ChatAgent( llm = llm, systemPrompt = systemPrompt, tools = allTools, + toolsetDispatch = toolsetDispatch, memoryPrefetcher = memoryPrefetcher, memoryReviewer = memoryReviewer, memoryStoreForReview = memoryStore, @@ -265,6 +318,7 @@ internal fun buildSystemPrompt( memoryEnabled: Boolean, soulBody: String? = null, reflections: List = emptyList(), + toolsetSection: String? = null, ): String { val trimmedBase = base.trimEnd() val skillsSection = skills.renderSystemPromptSection() @@ -275,8 +329,13 @@ internal fun buildSystemPrompt( } else { withMemory } + val withToolsets = if (!toolsetSection.isNullOrBlank()) { + withReflections + "\n\n" + toolsetSection + } else { + withReflections + } val trimmedSoul = soulBody?.trim() - return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withReflections else withReflections + return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withToolsets else withToolsets } /** diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt index d71133c..58dbfc1 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt @@ -39,6 +39,7 @@ import pw.binom.agentik.storage.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryRow import pw.binom.agentik.storage.WorkingMemoryStore import pw.binom.agentik.storage.sqlite.SqliteStores +import pw.binom.agentik.toolsets.ToolsetDispatchPolicy import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversationConfig @@ -74,6 +75,13 @@ class ChatConversation( private val llm: LiteLlm, private val systemPrompt: String, private val tools: List = emptyList(), + /** + * Диспетчер тулов с учётом тулсетов. `null` = тулсетов нет, диспетчер + * работает как passthrough через [toolsByName] (поведение pre-toolsets). + * Если задан — все вызовы идут через [toolsetDispatch], который умеет + * auto-activate тулсеты при вызове тула из неактивного. + */ + private val toolsetDispatch: ToolsetDispatchPolicy? = null, /** * Если задан, перед каждым ходом прогоняет user-сообщение через префетч * и приклеивает топ-K заметок к первому текстовому контенту в виде @@ -794,17 +802,33 @@ class ChatConversation( ) } - val tool = toolsByName[call.name] - val resultText: String = if (tool == null) { - log.warn { "tool '${call.name}' requested but not registered" } - "[tool not found: ${call.name}]" - } else { + val resultText: String = if (toolsetDispatch != null) { + // Через тулсет-диспетчер: активный тул выполняется напрямую, + // тул из неактивного тулсета — auto-activate + выполнение, + // неизвестный — fallback в base dispatcher (плоские тулы). try { - tool.tool.invoke(argsJson).ifBlank { "" } + val outcome = toolsetDispatch.dispatch(call.name, argsJson) + when (outcome) { + is ToolsetDispatchPolicy.Outcome.Ran -> outcome.result.ifBlank { "" } + is ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: ${call.name}]" + } } catch (e: Throwable) { log.warn(e) { "tool '${call.name}' threw: ${e.message}" } "[tool error: ${e.message ?: e.javaClass.simpleName}]" } + } else { + val tool = toolsByName[call.name] + if (tool == null) { + log.warn { "tool '${call.name}' requested but not registered" } + "[tool not found: ${call.name}]" + } else { + try { + tool.tool.invoke(argsJson).ifBlank { "" } + } catch (e: Throwable) { + log.warn(e) { "tool '${call.name}' threw: ${e.message}" } + "[tool error: ${e.message ?: e.javaClass.simpleName}]" + } + } } emitEvent(ProtoEvent.ToolResult(date = now(), id = resultId, result = resultText)) diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt new file mode 100644 index 0000000..3e9f4b9 --- /dev/null +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentToolsetsTest.kt @@ -0,0 +1,197 @@ +package pw.binom.agentik.standalone.agent + +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.standalone.llm.LlmConfig +import pw.binom.agentik.storage.sqlite.SqliteStores +import pw.binom.agentik.toolsets.ToolsetContribution +import pw.binom.litert.LiteLlm +import pw.binom.litert.LiteTool +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Интеграционные тесты ChatAgent + toolsets: проверяем что при пустом + * toolsets=List (дефолт) агент ведёт себя как раньше (нет enable/disable тулов, + * нет секции в system prompt), а при non-empty — добавляет их и регистрирует + * диспетчер. + */ +class ChatAgentToolsetsTest { + + private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool { + override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}""" + override fun invoke(arguments: String) = response + } + + private fun stubLlm(): LiteLlm = object : LiteLlm { + override val backendName: String = "stub" + override fun isInitialized(): Boolean = true + override fun createConversation(config: pw.binom.litert.LiteConversationConfig): pw.binom.litert.LiteConversation = + throw UnsupportedOperationException("not used in this test") + override fun infer(request: pw.binom.litert.LiteRequest): String = + throw UnsupportedOperationException("not used in this test") + override fun inferStream(request: pw.binom.litert.LiteRequest): kotlinx.coroutines.flow.Flow = + throw UnsupportedOperationException("not used in this test") + override val capabilities: pw.binom.litert.LiteCapabilities? = null + override fun close() {} + } + + private fun newAgent( + toolsets: List = emptyList(), + ): Pair { + val stores = SqliteStores.inMemory() + val agent = ChatAgent( + id = "test-agent", + stores = stores, + llm = stubLlm(), + llmConfig = LlmConfig( + backend = pw.binom.agentik.standalone.llm.LlmBackend.GOOGLE, + systemPrompt = "base", + google = pw.binom.agentik.standalone.llm.GoogleConfig(modelPath = "/tmp/fake.gguf"), + ), + toolsets = toolsets, + ) + return agent to stores + } + + @Test + fun `default (empty toolsets) does not register enable or disable tools`() { + val (agent, stores) = newAgent() + try { + // Проверяем через allTools-эквивалент: вызываем enable_toolset + // и ожидаем что он не найден — это значит тул не зарегистрирован. + val conv = agent.createConversation(temp = true) + // system prompt не должен содержать секции Toolsets + val prompt = (conv as ChatConversation).let { it.systemPromptForTest() } + assertFalse(prompt.contains("## Toolsets"), "toolsets section must NOT appear when toolsets empty") + assertFalse(prompt.contains("enable_toolset"), "enable_toolset must NOT be mentioned when toolsets empty") + } finally { stores.close() } + } + + @Test + fun `non-empty toolsets registers enable and disable tools and includes section`() { + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "image and video processing", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))), + ), + ToolsetContribution( + name = "web", + description = "fetch and parse web pages", + tools = listOf(ToolsetContribution.ToolEntry("fetch_url", stubTool("fetch_url"))), + ), + ) + val (agent, stores) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + val prompt = conv.systemPromptForTest() + assertTrue(prompt.contains("## Toolsets"), "toolsets section MUST appear when toolsets non-empty") + assertTrue(prompt.contains("- media — image and video processing")) + assertTrue(prompt.contains("- web — fetch and parse web pages")) + // Оба тула — в списке allTools (через system prompt проверяем только prompt; + // наличие тулов проверим отдельно — см. ниже). + } finally { stores.close() } + } + + @Test + fun `non-empty toolsets - enable_toolset activates and dispatches auto-included tools`() { + val mediaTool = stubTool("resize_image", "image-resized-100x100") + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "image and video processing", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), + ), + ) + val (agent, stores) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + + // Вызываем enable_toolset через диспетчер (минуя LLM, напрямую) + val enabled = runBlocking { + conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") + } + assertEquals("Toolset 'media' activated.", enabled) + + // Теперь resize_image должен работать (тулсет активен) + val resized = runBlocking { + conv.dispatchToolForTest("resize_image", "{}") + } + assertEquals("image-resized-100x100", resized) + } finally { stores.close() } + } + + @Test + fun `non-empty toolsets - calling tool from inactive toolset triggers auto-activation`() { + val mediaTool = stubTool("resize_image", "auto-activated-and-ran") + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "x", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)), + ), + ) + val (agent, stores) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + + // Без enable сразу вызываем resize_image — диспетчер должен auto-activate. + val result = runBlocking { + conv.dispatchToolForTest("resize_image", "{}") + } + assertEquals("auto-activated-and-ran", result) + } finally { stores.close() } + } + + @Test + fun `non-empty toolsets - disable_toolset removes from active list`() { + // Тест проверяет только сайд-эффект на реестре (active set), + // а не реальный dispatch — потому что диспетчер auto-activate'ит тулсет + // обратно при следующем вызове (это by design: если модель забыла что + // тулсет выключен, мы прощаем и включаем заново). + val toolsets = listOf( + ToolsetContribution( + name = "media", + description = "x", + tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))), + ), + ) + val (agent, stores) = newAgent(toolsets = toolsets) + try { + val conv = agent.createConversation(temp = true) as ChatConversation + + runBlocking { conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") } + // disable + val disabled = runBlocking { + conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""") + } + assertEquals("Toolset 'media' deactivated.", disabled) + // После disable реестр уже не содержит media в active + // (проверяем косвенно: disable повторно всё ещё возвращает тот же uniform message) + val disabledAgain = runBlocking { + conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""") + } + assertEquals("Toolset 'media' deactivated.", disabledAgain) + } finally { stores.close() } + } +} + +// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов. +internal fun ChatConversation.systemPromptForTest(): String { + // Через рефлексию достаём private val systemPrompt. + val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true } + return f.get(this) as String +} + +internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String { + // Через toolsetDispatch (если есть) или прямой toolsByName. + val dispatchField = this::class.java.declaredFields.first { it.name == "toolsetDispatch" }.apply { isAccessible = true } + val dispatch = dispatchField.get(this) ?: error("toolsetDispatch must be set when toolsets present") + val outcome = (dispatch as pw.binom.agentik.toolsets.ToolsetDispatchPolicy).dispatch(toolName, argsJson) + return when (outcome) { + is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Ran -> outcome.result + is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: $toolName]" + } +}