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:
@@ -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<ToolsetContribution> = emptyList(),
|
||||
) : 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, если задан) → база → секция навыков → секция памяти
|
||||
* → секция 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<NamedTool> = 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<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>(
|
||||
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<Reflection> = 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+30
-6
@@ -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<NamedTool> = 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 { "<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) {
|
||||
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 { "<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))
|
||||
|
||||
+197
@@ -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]"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user