agent-toolsets: SystemPromptToolsetSection + интеграция в ChatAgent

Добавлен SystemPromptToolsetSection — рендер markdown-секции для system prompt.
Контракт:
- toolsets пустой → null (секция не добавляется, агент не знает о механике)
- иначе → краткое описание концепции + список 'name — description' для
  активных и неактивных (одинаковый формат per design contract)
- auto-activation НЕ упоминается в промпте (только в dispatch)

Интеграция в ChatAgent:
- Добавлен параметр toolsets: List<ToolsetContribution> = 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)
This commit is contained in:
2026-09-15 15:06:23 +03:00
parent 2e1387273a
commit 1dc5552f98
6 changed files with 431 additions and 9 deletions
@@ -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<ToolsetContribution>,
inactive: List<ToolsetContribution>,
): 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>): 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)
}
}
@@ -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()))
}
}
+1
View File
@@ -49,6 +49,7 @@ kotlin {
implementation(project(":memory-vector")) implementation(project(":memory-vector"))
implementation(project(":storage-core")) implementation(project(":storage-core"))
implementation(project(":storage-sqlite")) implementation(project(":storage-sqlite"))
implementation(project(":agent-toolsets"))
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime // litert-google: встроенный LiteRT-LM движок, нужен только на runtime
runtimeOnly(libs.litert.google) runtimeOnly(libs.litert.google)
@@ -20,6 +20,12 @@ import pw.binom.agentik.storage.ConversationRecord
import pw.binom.agentik.storage.Reflection import pw.binom.agentik.storage.Reflection
import pw.binom.agentik.storage.WorkingMemoryEntry import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.sqlite.SqliteStores 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 pw.binom.litert.LiteLlm
import kotlin.time.Instant import kotlin.time.Instant
@@ -102,11 +108,31 @@ class ChatAgent(
* Через сколько пользовательских ходов запускать skill mining. `0` = выключено. * Через сколько пользовательских ходов запускать skill mining. `0` = выключено.
*/ */
private val skillMiningInterval: Int = 0, private val skillMiningInterval: Int = 0,
/**
* Тулсеты, доступные агенту. Пустой список (по умолчанию) — модель не знает
* о механике toolsets: enable_toolset/disable_toolset НЕ регистрируются,
* секция в system prompt НЕ добавляется (полная невидимость per A1-α).
*/
private val toolsets: List<ToolsetContribution> = emptyList(),
) : ProtoAgent, AutoCloseable { ) : ProtoAgent, AutoCloseable {
/**
* Реестр активных тулсетов — один на агента (per-agent state).
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations.
*/
private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets)
/** Список активных тулов (включая enable/disable если есть тулсеты). */
private val enabledToolsetTools: List<NamedTool> = if (toolsets.isNotEmpty()) {
listOf(
NamedTool(EnableToolsetTool.NAME, EnableToolsetTool(toolsetRegistry).tool),
NamedTool(DisableToolsetTool.NAME, DisableToolsetTool(toolsetRegistry).tool),
)
} else emptyList()
/** /**
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти * Системный промпт: (soul, если задан) → база → секция навыков → секция памяти
* → секция self-reflection (слабые места). * → секция self-reflection (слабые места) → секция тулсетов (если они заданы).
* Именно он сидируется в working memory и передаётся в [ChatConversation]. * Именно он сидируется в working memory и передаётся в [ChatConversation].
*/ */
private val systemPrompt: String = buildSystemPrompt( private val systemPrompt: String = buildSystemPrompt(
@@ -115,21 +141,46 @@ class ChatAgent(
memoryEnabled = memoryStore != null, memoryEnabled = memoryStore != null,
soulBody = soulBody, soulBody = soulBody,
reflections = recentReflections, reflections = recentReflections,
toolsetSection = if (toolsets.isNotEmpty()) {
SystemPromptToolsetSection.render(
active = emptyList(), // все по умолчанию неактивны
inactive = toolsets,
)
} else null,
) )
/** /**
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные: * Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные:
* `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть * `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть
* [skillStore]), `memory_*` (если подключена память). * [skillStore]), `memory_*` (если подключена память),
* `enable_toolset`/`disable_toolset` (если заданы [toolsets]).
* MCP-тулы префиксованы `server__`, так что коллизий нет. * MCP-тулы префиксованы `server__`, так что коллизий нет.
*/ */
private val allTools: List<NamedTool> = buildList { private val allTools: List<NamedTool> = buildList {
addAll(tools) addAll(tools)
addAll(enabledToolsetTools)
if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills))) if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills)))
if (skillStore != null) addAll(SkillToolsFactory.create(skillStore)) if (skillStore != null) addAll(SkillToolsFactory.create(skillStore))
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore)) if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
} }
private val toolsByName: Map<String, NamedTool> = 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<AgentEvent>( private val agentEvents = MutableSharedFlow<AgentEvent>(
extraBufferCapacity = 64, extraBufferCapacity = 64,
) )
@@ -174,6 +225,7 @@ class ChatAgent(
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
tools = allTools, tools = allTools,
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher, memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
memoryStoreForReview = memoryStore, memoryStoreForReview = memoryStore,
@@ -224,6 +276,7 @@ class ChatAgent(
llm = llm, llm = llm,
systemPrompt = systemPrompt, systemPrompt = systemPrompt,
tools = allTools, tools = allTools,
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher, memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer, memoryReviewer = memoryReviewer,
memoryStoreForReview = memoryStore, memoryStoreForReview = memoryStore,
@@ -265,6 +318,7 @@ internal fun buildSystemPrompt(
memoryEnabled: Boolean, memoryEnabled: Boolean,
soulBody: String? = null, soulBody: String? = null,
reflections: List<Reflection> = emptyList(), reflections: List<Reflection> = emptyList(),
toolsetSection: String? = null,
): String { ): String {
val trimmedBase = base.trimEnd() val trimmedBase = base.trimEnd()
val skillsSection = skills.renderSystemPromptSection() val skillsSection = skills.renderSystemPromptSection()
@@ -275,8 +329,13 @@ internal fun buildSystemPrompt(
} else { } else {
withMemory withMemory
} }
val withToolsets = if (!toolsetSection.isNullOrBlank()) {
withReflections + "\n\n" + toolsetSection
} else {
withReflections
}
val trimmedSoul = soulBody?.trim() val trimmedSoul = soulBody?.trim()
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withReflections else withReflections return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withToolsets else withToolsets
} }
/** /**
@@ -39,6 +39,7 @@ import pw.binom.agentik.storage.WorkingMemoryEntry
import pw.binom.agentik.storage.WorkingMemoryRow import pw.binom.agentik.storage.WorkingMemoryRow
import pw.binom.agentik.storage.WorkingMemoryStore import pw.binom.agentik.storage.WorkingMemoryStore
import pw.binom.agentik.storage.sqlite.SqliteStores import pw.binom.agentik.storage.sqlite.SqliteStores
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.litert.LiteContentPart import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig import pw.binom.litert.LiteConversationConfig
@@ -74,6 +75,13 @@ class ChatConversation(
private val llm: LiteLlm, private val llm: LiteLlm,
private val systemPrompt: String, private val systemPrompt: String,
private val tools: List<NamedTool> = emptyList(), private val tools: List<NamedTool> = emptyList(),
/**
* Диспетчер тулов с учётом тулсетов. `null` = тулсетов нет, диспетчер
* работает как passthrough через [toolsByName] (поведение pre-toolsets).
* Если задан — все вызовы идут через [toolsetDispatch], который умеет
* auto-activate тулсеты при вызове тула из неактивного.
*/
private val toolsetDispatch: ToolsetDispatchPolicy? = null,
/** /**
* Если задан, перед каждым ходом прогоняет user-сообщение через префетч * Если задан, перед каждым ходом прогоняет user-сообщение через префетч
* и приклеивает топ-K заметок к первому текстовому контенту в виде * и приклеивает топ-K заметок к первому текстовому контенту в виде
@@ -794,17 +802,33 @@ class ChatConversation(
) )
} }
val tool = toolsByName[call.name] val resultText: String = if (toolsetDispatch != null) {
val resultText: String = if (tool == null) { // Через тулсет-диспетчер: активный тул выполняется напрямую,
log.warn { "tool '${call.name}' requested but not registered" } // тул из неактивного тулсета — auto-activate + выполнение,
"[tool not found: ${call.name}]" // неизвестный — fallback в base dispatcher (плоские тулы).
} else {
try { try {
tool.tool.invoke(argsJson).ifBlank { "<empty result>" } val outcome = toolsetDispatch.dispatch(call.name, argsJson)
when (outcome) {
is ToolsetDispatchPolicy.Outcome.Ran -> outcome.result.ifBlank { "<empty result>" }
is ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: ${call.name}]"
}
} catch (e: Throwable) { } catch (e: Throwable) {
log.warn(e) { "tool '${call.name}' threw: ${e.message}" } log.warn(e) { "tool '${call.name}' threw: ${e.message}" }
"[tool error: ${e.message ?: e.javaClass.simpleName}]" "[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 { "<empty result>" }
} 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)) emitEvent(ProtoEvent.ToolResult(date = now(), id = resultId, result = resultText))
@@ -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<pw.binom.litert.LiteDelta> =
throw UnsupportedOperationException("not used in this test")
override val capabilities: pw.binom.litert.LiteCapabilities? = null
override fun close() {}
}
private fun newAgent(
toolsets: List<ToolsetContribution> = emptyList(),
): Pair<ChatAgent, SqliteStores> {
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]"
}
}