diff --git a/core/src/commonJvmAndroidMain/kotlin/com/google/adk/kt/skills/SkillMdParsing.kt b/core/src/commonJvmAndroidMain/kotlin/com/google/adk/kt/skills/SkillMdParsing.kt index 1741b26a..2e851dfb 100644 --- a/core/src/commonJvmAndroidMain/kotlin/com/google/adk/kt/skills/SkillMdParsing.kt +++ b/core/src/commonJvmAndroidMain/kotlin/com/google/adk/kt/skills/SkillMdParsing.kt @@ -107,6 +107,24 @@ internal fun buildValidatedFrontmatter( ) } + val metadata = + when (val metadataValue = frontmatterMap["metadata"]) { + null -> emptyMap() + is Map<*, *> -> + metadataValue + .mapNotNull { (k, v) -> + when { + k !is String -> null + k == Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY -> k to v + v is String -> k to v + else -> null + } + } + .toMap() + else -> throw SkillSourceException("Skill $skillName is malformed: metadata must be a map") + } + val adkAdditionalTools = parseAdkAdditionalToolsMetadata(skillName, metadata) + return try { Frontmatter( name = name, @@ -115,16 +133,37 @@ internal fun buildValidatedFrontmatter( compatibility = frontmatterMap["compatibility"] as? String, allowedTools = frontmatterMap["allowed-tools"] as? String ?: frontmatterMap["allowed_tools"] as? String, - metadata = - (frontmatterMap["metadata"] as? Map<*, *>) - ?.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null } - ?.toMap() ?: emptyMap(), + metadata = metadata, + adkAdditionalTools = adkAdditionalTools, ) } catch (e: IllegalArgumentException) { throw SkillSourceException("Skill $skillName is malformed: ${e.message}", e) } } +private fun parseAdkAdditionalToolsMetadata( + skillName: String, + metadata: Map, +): List? { + val value = metadata[Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY] ?: return null + if (value !is List<*>) { + throw SkillSourceException( + "Skill $skillName is malformed: metadata.${Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY} must be a list of strings" + ) + } + return value.map { item -> + val text = + item as? String + ?: throw SkillSourceException( + "Skill $skillName is malformed: metadata.${Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY} must be a list of strings" + ) + text.trim().takeIf { it.isNotEmpty() } + ?: throw SkillSourceException( + "Skill $skillName is malformed: metadata.${Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY} must not contain blank tool names" + ) + } +} + /** * Parses [content] (the raw contents of a [SKILL_FILE_NAME] file) belonging to a skill named * [skillName] into a validated [Frontmatter] and the instruction body, wrapping any parsing or diff --git a/core/src/commonJvmAndroidTest/kotlin/com/google/adk/kt/skills/NewFileSystemSourceTest.kt b/core/src/commonJvmAndroidTest/kotlin/com/google/adk/kt/skills/NewFileSystemSourceTest.kt index 71b87302..9575915e 100644 --- a/core/src/commonJvmAndroidTest/kotlin/com/google/adk/kt/skills/NewFileSystemSourceTest.kt +++ b/core/src/commonJvmAndroidTest/kotlin/com/google/adk/kt/skills/NewFileSystemSourceTest.kt @@ -203,6 +203,88 @@ class NewFileSystemSourceTest { assertThat(result.getOrThrow().name).isEqualTo("skill1") } + @Test + fun loadFrontmatter_adkAdditionalToolsMetadata_returnsFrontmatterWithMetadata() = runTest { + writeRawSkillMd( + dirName = "test-skill", + content = + """ + --- + name: test-skill + description: Description + metadata: + adk_additional_tools: + - extra_tool_a + - extra_tool_b + owner: adk + --- + Instructions. + """ + .trimIndent(), + ) + + val result = source.loadFrontmatter("test-skill") + + assertThat(result.isSuccess).isTrue() + val frontmatter = result.getOrThrow() + assertThat(frontmatter.adkAdditionalTools).containsExactly("extra_tool_a", "extra_tool_b") + assertThat(frontmatter.metadata[Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY]) + .isEqualTo(listOf("extra_tool_a", "extra_tool_b")) + assertThat(frontmatter.metadata["owner"]).isEqualTo("adk") + } + + @Test + fun loadFrontmatter_adkAdditionalToolsMetadataNotList_returnsFailure() = runTest { + writeRawSkillMd( + dirName = "test-skill", + content = + """ + --- + name: test-skill + description: Description + metadata: + adk_additional_tools: extra_tool_a + --- + Instructions. + """ + .trimIndent(), + ) + + val result = source.loadFrontmatter("test-skill") + + assertThat(result.isFailure).isTrue() + val exception = result.exceptionOrNull() + assertThat(exception).isInstanceOf(SkillSourceException::class.java) + assertThat(exception!!.message).contains("adk_additional_tools must be a list of strings") + } + + @Test + fun loadFrontmatter_adkAdditionalToolsMetadataBlankItem_returnsFailure() = runTest { + writeRawSkillMd( + dirName = "test-skill", + content = + """ + --- + name: test-skill + description: Description + metadata: + adk_additional_tools: + - extra_tool_a + - " " + --- + Instructions. + """ + .trimIndent(), + ) + + val result = source.loadFrontmatter("test-skill") + + assertThat(result.isFailure).isTrue() + val exception = result.exceptionOrNull() + assertThat(exception).isInstanceOf(SkillSourceException::class.java) + assertThat(exception!!.message).contains("adk_additional_tools must not contain blank tool names") + } + @Test fun loadFrontmatter_nonexistentSkill_returnsFailure() = runTest { val result = source.loadFrontmatter("nonexistent") diff --git a/core/src/commonMain/kotlin/com/google/adk/kt/skills/Frontmatter.kt b/core/src/commonMain/kotlin/com/google/adk/kt/skills/Frontmatter.kt index 959763e4..ae687341 100644 --- a/core/src/commonMain/kotlin/com/google/adk/kt/skills/Frontmatter.kt +++ b/core/src/commonMain/kotlin/com/google/adk/kt/skills/Frontmatter.kt @@ -30,6 +30,11 @@ package com.google.adk.kt.skills * @property compatibility The compatibility of the skill. * @property allowedTools The tools that are allowed to be used by the skill. * @property metadata Additional metadata about the skill. + * @property adkAdditionalTools Names of [com.google.adk.kt.tools.BaseTool]s that the + * [com.google.adk.kt.tools.SkillToolset] should expose to the LLM only after this skill has been + * loaded via `load_skill`. Mirrors the adk-python frontmatter key + * `metadata.adk_additional_tools`. When non-null, the values must be non-blank tool names that + * match tools passed to the toolset as `additionalTools`. * @throws IllegalArgumentException if any field violates the frontmatter specification. */ data class Frontmatter( @@ -38,8 +43,26 @@ data class Frontmatter( val license: String? = null, val compatibility: String? = null, val allowedTools: String? = null, - val metadata: Map = emptyMap(), + val metadata: Map = emptyMap(), + val adkAdditionalTools: List? = null, ) { + constructor( + name: String, + description: String, + license: String?, + compatibility: String?, + allowedTools: String?, + metadata: Map, + ) : this( + name = name, + description = description, + license = license, + compatibility = compatibility, + allowedTools = allowedTools, + metadata = metadata as Map, + adkAdditionalTools = null, + ) + init { require(name.isNotEmpty() && name.length <= 64) { "name must be between 1 and 64 characters long" @@ -57,5 +80,31 @@ data class Frontmatter( require(compatibility == null || compatibility.length <= 500) { "compatibility must not exceed 500 characters" } + require(adkAdditionalTools?.all { it.isNotBlank() } ?: true) { + "adkAdditionalTools must not contain blank tool names" + } + require(metadataAdkAdditionalToolsOrNull()?.all { it.isNotBlank() } ?: true) { + "metadata.adk_additional_tools must not contain blank tool names" + } + require( + metadata[ADK_ADDITIONAL_TOOLS_METADATA_KEY] == null || metadataAdkAdditionalToolsOrNull() != null + ) { + "metadata.adk_additional_tools must be a list of strings" + } + } + + /** Returns the additional tool names declared through either Kotlin or ADK Python-style metadata. */ + fun additionalToolNames(): List? = + adkAdditionalTools ?: metadataAdkAdditionalToolsOrNull() + + private fun metadataAdkAdditionalToolsOrNull(): List? { + val value = metadata[ADK_ADDITIONAL_TOOLS_METADATA_KEY] ?: return null + if (value !is List<*>) return null + return value.mapNotNull { item -> item as? String } + .takeIf { it.size == value.size } + } + + companion object { + const val ADK_ADDITIONAL_TOOLS_METADATA_KEY = "adk_additional_tools" } } diff --git a/core/src/commonMain/kotlin/com/google/adk/kt/tools/SkillToolset.kt b/core/src/commonMain/kotlin/com/google/adk/kt/tools/SkillToolset.kt index c91053ba..5b528c4e 100644 --- a/core/src/commonMain/kotlin/com/google/adk/kt/tools/SkillToolset.kt +++ b/core/src/commonMain/kotlin/com/google/adk/kt/tools/SkillToolset.kt @@ -77,15 +77,25 @@ internal class ListSkillsTool(private val toolset: SkillToolset) : } } -private fun Frontmatter.frontmatterDsl() = - mapOf( +private fun Frontmatter.frontmatterDsl(): Map { + val metadataWithAdditionalTools = + if ( + adkAdditionalTools != null && + Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY !in metadata + ) { + metadata + (Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY to adkAdditionalTools) + } else { + metadata + } + return mapOf( "name" to name, "description" to description, "license" to license, "compatibility" to compatibility, "allowed_tools" to allowedTools, - "metadata" to metadata, + "metadata" to metadataWithAdditionalTools, ) +} /** BaseTool responsible for loading the instructions for a specific skill. */ internal class LoadSkillTool(private val toolset: SkillToolset) : @@ -127,12 +137,42 @@ internal class LoadSkillTool(private val toolset: SkillToolset) : return e.toSkillSourceErrorResponse(logger) } + recordSkillActivation(context, skillName) + return mapOf( SkillToolset.PARAM_SKILL_NAME to skillName, SkillToolset.KEY_INSTRUCTIONS to instructions, SkillToolset.KEY_FRONTMATTER to frontmatter.frontmatterDsl(), ) } + + /** + * Appends [skillName] to the per-agent activation list for this invocation. + * + * The activation state is split across two stores in adk-kotlin: the pending + * [ToolContext.actions.stateDelta] (not yet applied to the session) and the already-applied + * [ToolContext.context.state]. To preserve other skills that have been activated earlier in the + * same invocation, both stores are merged before writing the updated list back into + * `stateDelta`. State is keyed by [SkillToolset.activatedSkillStateKey] so each agent keeps its + * own activation list. + * + * Note: when the LLM emits multiple `load_skill` calls in the same step, ADK runs them in + * parallel and the per-call deltas are merged with last-write-wins semantics + * ([EventActions.mergeWith]). Only the skill recorded by the final merge survives the step. This + * matches adk-python's behavior and is a known limitation; downstream tool exposure still works + * for serial `load_skill` calls across steps. + */ + private fun recordSkillActivation(context: ToolContext, skillName: String) { + val agentName = context.context.agentName + val stateKey = SkillToolset.activatedSkillStateKey(agentName) + val pending = (context.actions.stateDelta[stateKey] as? List<*>).orEmpty() + val applied = (context.context.state[stateKey] as? List<*>).orEmpty() + val combined = + (applied + pending + skillName) + .mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } + .distinct() + context.actions.stateDelta[stateKey] = combined + } } /** BaseTool responsible for loading resources (references/assets/scripts) from a specific skill. */ @@ -207,7 +247,18 @@ internal class LoadSkillResourceTool(private val toolset: SkillToolset) : } /** Toolset that manages and provides access to a collection of [Skill]s. */ -class SkillToolset(internal val source: SkillSource) : Toolset { +class SkillToolset( + internal val source: SkillSource, + additionalTools: List, + additionalToolsets: List, +) : Toolset { + + constructor(source: SkillSource) : this(source, emptyList(), emptyList()) + + constructor( + source: SkillSource, + additionalTools: List, + ) : this(source, additionalTools, emptyList()) companion object { /** The name of the tool used to list available skills. */ @@ -235,14 +286,119 @@ class SkillToolset(internal val source: SkillSource) : Toolset { /** Message indicating that a loaded resource is a binary file. */ const val MSG_BINARY_FILE = "Binary file detected. Content not shown." + + /** + * Prefix for the per-agent state key under which the SkillToolset records which skills the LLM + * has activated by calling `load_skill`. Mirrors adk-python's `_adk_activated_skill_` prefix. + */ + const val STATE_KEY_PREFIX_ACTIVATED_SKILL = "_adk_activated_skill_" + + /** Builds the activation-state key for the given agent name. */ + fun activatedSkillStateKey(agentName: String): String = + STATE_KEY_PREFIX_ACTIVATED_SKILL + agentName } private val logger = LoggerFactory.getLogger(SkillToolset::class) + /** + * Additional tools that are hidden from the LLM until a skill declares them in its frontmatter's + * `adk_additional_tools` and that skill has been loaded via `load_skill`. Keyed by tool name; + * duplicates are dropped with a warning (last one wins). + */ + internal val providedToolsByName: Map = + run { + val byName = LinkedHashMap() + for (tool in additionalTools) { + val previous = byName.put(tool.name, tool) + if (previous != null) { + logger.warn { "Duplicate additional tool name '${tool.name}'; last one wins." } + } + } + byName + } + + /** + * Additional toolsets that can contribute tools after activation. This mirrors adk-python's + * ability to resolve additional tools from both tools and toolsets, while keeping the Kotlin API + * strongly typed. + */ + private val providedToolsets: List = additionalToolsets + private val tools: List = listOf(ListSkillsTool(this), LoadSkillTool(this), LoadSkillResourceTool(this)) - override suspend fun getTools(readonlyContext: ReadonlyContext?): List = tools + override suspend fun getTools(readonlyContext: ReadonlyContext?): List = + tools + resolveAdditionalToolsFromState(readonlyContext) + + /** + * Resolves the additional tools that should be exposed for this invocation, based on which skills + * have been activated via `load_skill`. + * + * The activation list is read from [ReadonlyContext.state] under the agent-specific key built by + * [activatedSkillStateKey]. For each activated skill, its `adk_additional_tools` frontmatter is + * consulted to pick tools from the provided tools and toolsets. Unknown skill names (e.g. leftover + * state from a previous session) and tool names with no candidate match are skipped silently. + * Names that collide with the core skill tools are dropped with a warning. + */ + private suspend fun resolveAdditionalToolsFromState( + readonlyContext: ReadonlyContext?, + ): List { + if (readonlyContext == null) return emptyList() + if (providedToolsByName.isEmpty() && providedToolsets.isEmpty()) return emptyList() + + val stateKey = activatedSkillStateKey(readonlyContext.agentName) + val rawActivated = readonlyContext.state[stateKey] + val skillNames = + (rawActivated as? List<*>) + .orEmpty() + .mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } + if (skillNames.isEmpty()) return emptyList() + + val existingNames = tools.mapTo(mutableSetOf()) { it.name } + val additionalToolNames = LinkedHashSet() + for (skillName in skillNames) { + val frontmatter = + source.loadFrontmatter(skillName).getOrElse { e -> + logger.warn(e) { "Activated skill '$skillName' could not be loaded; skipping." } + null + } ?: continue + additionalToolNames.addAll(frontmatter.additionalToolNames().orEmpty()) + } + if (additionalToolNames.isEmpty()) return emptyList() + + val candidateTools = getCandidateTools(readonlyContext) + val resolved = LinkedHashMap() + for (toolName in additionalToolNames) { + if (toolName in existingNames) { + logger.warn { + "Tool name collision: additional tool '$toolName' shadows a core skill tool; skipping." + } + continue + } + val tool = candidateTools[toolName] ?: continue + if (resolved.put(toolName, tool) == null) { + existingNames.add(toolName) + } + } + return resolved.values.toList() + } + + private suspend fun getCandidateTools(readonlyContext: ReadonlyContext): Map { + val candidateTools = LinkedHashMap() + candidateTools.putAll(providedToolsByName) + for (toolset in providedToolsets) { + for (tool in toolset.getTools(readonlyContext)) { + candidateTools[tool.name] = tool + } + } + return candidateTools + } + + override fun close() { + tools.forEach { it.close() } + providedToolsByName.values.forEach { it.close() } + providedToolsets.forEach { it.close() } + } override suspend fun processLlmRequest( toolContext: ToolContext, diff --git a/core/src/commonTest/kotlin/com/google/adk/kt/skills/FrontmatterTest.kt b/core/src/commonTest/kotlin/com/google/adk/kt/skills/FrontmatterTest.kt index 6a816362..cbf7aea3 100644 --- a/core/src/commonTest/kotlin/com/google/adk/kt/skills/FrontmatterTest.kt +++ b/core/src/commonTest/kotlin/com/google/adk/kt/skills/FrontmatterTest.kt @@ -156,4 +156,73 @@ class FrontmatterTest { assertThat(e).hasMessageThat().contains("compatibility must not exceed 500 characters") } + + @Test + fun init_adkAdditionalTools_null_succeeds() { + val fm = + Frontmatter(name = "valid-name", description = "valid description", adkAdditionalTools = null) + assertThat(fm.adkAdditionalTools).isNull() + } + + @Test + fun init_adkAdditionalTools_nonEmptyList_succeeds() { + val fm = + Frontmatter( + name = "valid-name", + description = "valid description", + adkAdditionalTools = listOf("tool_a", "tool_b"), + ) + assertThat(fm.adkAdditionalTools).containsExactly("tool_a", "tool_b") + } + + @Test + fun init_metadataAdkAdditionalTools_nonEmptyList_succeeds() { + val fm = + Frontmatter( + name = "valid-name", + description = "valid description", + metadata = + mapOf(Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY to listOf("tool_a", "tool_b")), + ) + assertThat(fm.additionalToolNames()).containsExactly("tool_a", "tool_b") + } + + @Test + fun init_adkAdditionalTools_emptyList_succeeds() { + val fm = + Frontmatter( + name = "valid-name", + description = "valid description", + adkAdditionalTools = emptyList(), + ) + assertThat(fm.adkAdditionalTools).isEmpty() + } + + @Test + fun init_adkAdditionalTools_containsBlank_throwsException() { + val e = + assertThrows(IllegalArgumentException::class.java) { + Frontmatter( + name = "valid-name", + description = "valid description", + adkAdditionalTools = listOf("tool_a", " "), + ) + } + assertThat(e).hasMessageThat().contains("adkAdditionalTools must not contain blank tool names") + } + + @Test + fun init_metadataAdkAdditionalTools_notList_throwsException() { + val e = + assertThrows(IllegalArgumentException::class.java) { + Frontmatter( + name = "valid-name", + description = "valid description", + metadata = mapOf(Frontmatter.ADK_ADDITIONAL_TOOLS_METADATA_KEY to "tool_a"), + ) + } + assertThat(e) + .hasMessageThat() + .contains("metadata.adk_additional_tools must be a list of strings") + } } diff --git a/core/src/commonTest/kotlin/com/google/adk/kt/testing/TestSession.kt b/core/src/commonTest/kotlin/com/google/adk/kt/testing/TestSession.kt index 87ce3fc9..c187e3d2 100644 --- a/core/src/commonTest/kotlin/com/google/adk/kt/testing/TestSession.kt +++ b/core/src/commonTest/kotlin/com/google/adk/kt/testing/TestSession.kt @@ -17,15 +17,24 @@ package com.google.adk.kt.testing import com.google.adk.kt.sessions.Session import com.google.adk.kt.sessions.SessionKey +import com.google.adk.kt.sessions.State /** * A [Session] with a [SessionKey] and otherwise default state and event list. * * Convenience for tests that need a session value object but don't care about its contents. For - * tests that need a specific [SessionKey], pass `key = SessionKey(...)`. + * tests that need a specific [SessionKey], pass `key = SessionKey(...)`. For tests that need to + * pre-seed session state (e.g. the SkillToolset activation list), pass + * `state = mapOf("_adk_activated_skill_..." to listOf("skill_name"))` — it is wrapped in a [State] + * with no delta. */ fun testSession( appName: String = "test_app_name", userId: String = "test_user_id", id: String? = "test_session_id", -): Session = Session(key = SessionKey(appName = appName, userId = userId, id = id)) + state: Map = emptyMap(), +): Session = + Session( + key = SessionKey(appName = appName, userId = userId, id = id), + state = State(initialState = state), + ) diff --git a/core/src/commonTest/kotlin/com/google/adk/kt/tools/SkillToolsetTest.kt b/core/src/commonTest/kotlin/com/google/adk/kt/tools/SkillToolsetTest.kt index bbdd4032..dc781bde 100644 --- a/core/src/commonTest/kotlin/com/google/adk/kt/tools/SkillToolsetTest.kt +++ b/core/src/commonTest/kotlin/com/google/adk/kt/tools/SkillToolsetTest.kt @@ -16,9 +16,14 @@ package com.google.adk.kt.tools +import com.google.adk.kt.agents.ReadonlyContext +import com.google.adk.kt.agents.toReadonlyContext +import com.google.adk.kt.events.EventActions import com.google.adk.kt.skills.Frontmatter import com.google.adk.kt.skills.SkillSource import com.google.adk.kt.skills.SkillSourceException +import com.google.adk.kt.testing.testInvocationContext +import com.google.adk.kt.testing.testSession import com.google.adk.kt.testing.testToolContext import kotlin.test.Test import kotlin.test.assertEquals @@ -34,8 +39,18 @@ class SkillToolsetTest { listOf( Frontmatter(name = "skill1", description = "Description 1"), Frontmatter(name = "skill2", description = "Description 2"), + Frontmatter( + name = "skill3", + description = "Description 3", + adkAdditionalTools = listOf("extra_tool_a", "extra_tool_b"), + ), + ) + private val mockInstructions = + mapOf( + "skill1" to "Instructions 1", + "skill2" to "Instructions 2", + "skill3" to "Instructions 3", ) - private val mockInstructions = mapOf("skill1" to "Instructions 1", "skill2" to "Instructions 2") private val mockSource = object : SkillSource { @@ -106,7 +121,7 @@ class SkillToolsetTest { val result = tool.run(testToolContext(), emptyMap()) as Map<*, *> val skillsList = result["skills"] as? List> assertNotNull(skillsList) - assertEquals(2, skillsList.size) + assertEquals(3, skillsList.size) assertEquals("skill1", skillsList[0]["name"]) assertEquals("Description 1", skillsList[0]["description"]) assertEquals("skill2", skillsList[1]["name"]) @@ -129,6 +144,20 @@ class SkillToolsetTest { assertEquals("Description 1", frontmatter["description"]) } + @Test + fun loadSkillTool_run_returnsAdkAdditionalToolsInFrontmatterMetadata() = runTest { + val tools = skillToolset.getTools(null) + val loadSkillTool = tools.first { it.name == SkillToolset.TOOL_NAME_LOAD_SKILL } + + val result = + loadSkillTool.run(testToolContext(), mapOf(SkillToolset.PARAM_SKILL_NAME to "skill3")) + as Map<*, *> + + val frontmatter = result[SkillToolset.KEY_FRONTMATTER] as Map<*, *> + val metadata = frontmatter["metadata"] as Map<*, *> + assertEquals(listOf("extra_tool_a", "extra_tool_b"), metadata["adk_additional_tools"]) + } + @Test fun loadSkillTool_run_requiresName() = runTest { val tools = skillToolset.getTools(null) @@ -433,4 +462,143 @@ class SkillToolsetTest { kotlin.test.assertNull(instruction) } + + @Test + fun getTools_returnsOnlyCoreTools_whenAdditionalToolsAndNoActivation() = runTest { + val toolset = + SkillToolset( + source = mockSource, + additionalTools = listOf(makeAdditionalTool("extra_tool_a"), makeAdditionalTool("extra_tool_b")), + ) + val tools = toolset.getTools(null) + assertEquals(3, tools.size) + assertTrue(tools.none { it.name == "extra_tool_a" || it.name == "extra_tool_b" }) + } + + @Test + fun getTools_exposesAdditionalTools_afterActivation() = runTest { + val toolset = + SkillToolset( + source = mockSource, + additionalTools = listOf(makeAdditionalTool("extra_tool_a"), makeAdditionalTool("extra_tool_b")), + ) + val session = + testSession( + state = mapOf(SkillToolset.activatedSkillStateKey("test-agent") to listOf("skill3")), + ) + val readonlyCtx = testInvocationContext(session = session).toReadonlyContext() + val tools = toolset.getTools(readonlyCtx) + assertEquals(5, tools.size) + assertTrue(tools.any { it.name == "extra_tool_a" }) + assertTrue(tools.any { it.name == "extra_tool_b" }) + } + + @Test + fun getTools_skipsUnknownSkillInActivationState() = runTest { + val toolset = + SkillToolset(source = mockSource, additionalTools = listOf(makeAdditionalTool("extra_tool_a"))) + val session = + testSession( + state = + mapOf(SkillToolset.activatedSkillStateKey("test-agent") to listOf("does-not-exist")), + ) + val readonlyCtx = testInvocationContext(session = session).toReadonlyContext() + val tools = toolset.getTools(readonlyCtx) + assertEquals(3, tools.size) + assertTrue(tools.none { it.name == "extra_tool_a" }) + } + + @Test + fun getTools_skipsToolNameNotInProvidedTools() = runTest { + // skill3 declares both extra_tool_a and extra_tool_b, but only extra_tool_a is provided. + val toolset = + SkillToolset(source = mockSource, additionalTools = listOf(makeAdditionalTool("extra_tool_a"))) + val session = + testSession( + state = mapOf(SkillToolset.activatedSkillStateKey("test-agent") to listOf("skill3")), + ) + val readonlyCtx = testInvocationContext(session = session).toReadonlyContext() + val tools = toolset.getTools(readonlyCtx) + assertEquals(4, tools.size) + assertTrue(tools.any { it.name == "extra_tool_a" }) + assertTrue(tools.none { it.name == "extra_tool_b" }) + } + + @Test + fun getTools_resolvesAdditionalToolsFromProvidedToolsets_afterActivation() = runTest { + val toolset = + SkillToolset( + source = mockSource, + additionalTools = emptyList(), + additionalToolsets = + listOf( + makeAdditionalToolset( + makeAdditionalTool("extra_tool_a"), + makeAdditionalTool("extra_tool_b"), + ) + ), + ) + val session = + testSession( + state = mapOf(SkillToolset.activatedSkillStateKey("test-agent") to listOf("skill3")), + ) + val readonlyCtx = testInvocationContext(session = session).toReadonlyContext() + + val tools = toolset.getTools(readonlyCtx) + + assertEquals(5, tools.size) + assertTrue(tools.any { it.name == "extra_tool_a" }) + assertTrue(tools.any { it.name == "extra_tool_b" }) + } + + @Test + fun loadSkillTool_run_writesActivationStateDelta() = runTest { + val actions = EventActions() + val ctx = testToolContext(actions = actions) + val toolset = SkillToolset(source = mockSource) + val loadSkillTool = toolset.getTools(null).first { it.name == SkillToolset.TOOL_NAME_LOAD_SKILL } + + loadSkillTool.run(ctx, mapOf(SkillToolset.PARAM_SKILL_NAME to "skill1")) + + val stateKey = SkillToolset.activatedSkillStateKey("test-agent") + val activated = actions.stateDelta[stateKey] as? List<*> + assertNotNull(activated) + assertTrue(activated.any { it?.toString() == "skill1" }) + } + + @Test + fun loadSkillTool_run_preservesPreviouslyActivatedSkills() = runTest { + // Session state already has skill3 activated; loading skill1 must produce a delta that + // contains both, otherwise the merge would drop skill3. + val session = + testSession( + state = mapOf(SkillToolset.activatedSkillStateKey("test-agent") to listOf("skill3")), + ) + val actions = EventActions() + val ctx = testToolContext(invocationContext = testInvocationContext(session = session), actions = actions) + val toolset = SkillToolset(source = mockSource) + val loadSkillTool = toolset.getTools(null).first { it.name == SkillToolset.TOOL_NAME_LOAD_SKILL } + + loadSkillTool.run(ctx, mapOf(SkillToolset.PARAM_SKILL_NAME to "skill1")) + + val stateKey = SkillToolset.activatedSkillStateKey("test-agent") + val activated = actions.stateDelta[stateKey] as? List<*> + assertNotNull(activated) + assertTrue(activated.any { it?.toString() == "skill1" }) + assertTrue(activated.any { it?.toString() == "skill3" }) + } } + +/** Minimal [BaseTool] implementation for SkillToolset activation tests. */ +private fun makeAdditionalTool(name: String): BaseTool = + object : + BaseTool(name = name, description = "Additional tool $name for tests.") { + override fun declaration() = null + + override suspend fun run(context: ToolContext, args: Map): Any = emptyMap() + } + +private fun makeAdditionalToolset(vararg tools: BaseTool): Toolset = + object : Toolset { + override suspend fun getTools(readonlyContext: ReadonlyContext?) = tools.toList() + }