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()))
}
}