Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String, Any?>,
): List<String>? {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -38,8 +43,26 @@ data class Frontmatter(
val license: String? = null,
val compatibility: String? = null,
val allowedTools: String? = null,
val metadata: Map<String, String> = emptyMap(),
val metadata: Map<String, Any?> = emptyMap(),
val adkAdditionalTools: List<String>? = null,
) {
constructor(
name: String,
description: String,
license: String?,
compatibility: String?,
allowedTools: String?,
metadata: Map<String, String>,
) : this(
name = name,
description = description,
license = license,
compatibility = compatibility,
allowedTools = allowedTools,
metadata = metadata as Map<String, Any?>,
adkAdditionalTools = null,
)

init {
require(name.isNotEmpty() && name.length <= 64) {
"name must be between 1 and 64 characters long"
Expand All @@ -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<String>? =
adkAdditionalTools ?: metadataAdkAdditionalToolsOrNull()

private fun metadataAdkAdditionalToolsOrNull(): List<String>? {
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"
}
}
Loading