From 52250da81f237002e4e6df2efeeae750eeebbcc7 Mon Sep 17 00:00:00 2001 From: Kathy Wu Date: Thu, 19 Mar 2026 13:39:22 -0700 Subject: [PATCH] fix: Allow snake case for skill name Kebab-case is the standard for skill names/directories given by the Skill spec. However, to enable support for importing within skill scripts (python does not allow imports with kebab-case, only snake case), allow snake case for the skill name. Co-authored-by: Kathy Wu PiperOrigin-RevId: 886351132 Change-Id: Ifb6c60603aaa00a399e4f5f14cd51c35737c25c1 --- src/google/adk/features/_feature_registry.py | 4 +++ src/google/adk/skills/models.py | 30 ++++++++++++++++---- tests/unittests/skills/test_models.py | 29 +++++++++++++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/google/adk/features/_feature_registry.py b/src/google/adk/features/_feature_registry.py index 5e8463cb..162f6ee6 100644 --- a/src/google/adk/features/_feature_registry.py +++ b/src/google/adk/features/_feature_registry.py @@ -54,6 +54,7 @@ class FeatureName(str, Enum): TOOL_CONFIG = "TOOL_CONFIG" TOOL_CONFIRMATION = "TOOL_CONFIRMATION" PLUGGABLE_AUTH = "PLUGGABLE_AUTH" + SNAKE_CASE_SKILL_NAME = "SNAKE_CASE_SKILL_NAME" V1_LLM_AGENT = "V1_LLM_AGENT" @@ -174,6 +175,9 @@ _FEATURE_REGISTRY: dict[FeatureName, FeatureConfig] = { FeatureName.PLUGGABLE_AUTH: FeatureConfig( FeatureStage.EXPERIMENTAL, default_on=True ), + FeatureName.SNAKE_CASE_SKILL_NAME: FeatureConfig( + FeatureStage.EXPERIMENTAL, default_on=False + ), } # Track which experimental features have already warned (warn only once) diff --git a/src/google/adk/skills/models.py b/src/google/adk/skills/models.py index cda1447b..1cd443b2 100644 --- a/src/google/adk/skills/models.py +++ b/src/google/adk/skills/models.py @@ -26,14 +26,20 @@ from pydantic import ConfigDict from pydantic import Field from pydantic import field_validator -_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +from ..features import FeatureName +from ..features import is_feature_enabled + +_KEBAB_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +_SNAKE_OR_KEBAB_NAME_PATTERN = re.compile( + r"^([a-z0-9]+(-[a-z0-9]+)*|[a-z0-9]+(_[a-z0-9]+)*)$" +) class Frontmatter(BaseModel): """L1 skill content: metadata parsed from SKILL.md for skill discovery. Attributes: - name: Skill name in kebab-case (required). + name: Skill name in kebab-case or snake_case (required). description: What the skill does and when the model should use it (required). license: License for the skill (optional). @@ -78,11 +84,23 @@ class Frontmatter(BaseModel): v = unicodedata.normalize("NFKC", v) if len(v) > 64: raise ValueError("name must be at most 64 characters") - if not _NAME_PATTERN.match(v): - raise ValueError( - "name must be lowercase kebab-case (a-z, 0-9, hyphens)," - " with no leading, trailing, or consecutive hyphens" + if is_feature_enabled(FeatureName.SNAKE_CASE_SKILL_NAME): + pattern = _SNAKE_OR_KEBAB_NAME_PATTERN + msg = ( + "name must be lowercase kebab-case (a-z, 0-9, hyphens) or" + " snake_case (a-z, 0-9, underscores), with no leading, trailing," + " or consecutive delimiters. Mixing hyphens and underscores is" + " not allowed." ) + else: + pattern = _KEBAB_NAME_PATTERN + msg = ( + "name must be lowercase kebab-case (a-z, 0-9," + " hyphens), with no leading, trailing, or" + " consecutive delimiters" + ) + if not pattern.match(v): + raise ValueError(msg) return v @field_validator("description") diff --git a/tests/unittests/skills/test_models.py b/tests/unittests/skills/test_models.py index 5685e9d8..73136be0 100644 --- a/tests/unittests/skills/test_models.py +++ b/tests/unittests/skills/test_models.py @@ -14,6 +14,8 @@ """Unit tests for skill models.""" +from google.adk.features import FeatureName +from google.adk.features._feature_registry import temporary_feature_override from google.adk.skills import models from pydantic import ValidationError import pytest @@ -99,16 +101,39 @@ def test_name_consecutive_hyphens(): models.Frontmatter(name="my--skill", description="desc") -def test_name_invalid_chars_underscore(): +def test_name_underscore_rejected_by_default(): with pytest.raises(ValidationError, match="lowercase kebab-case"): models.Frontmatter(name="my_skill", description="desc") +def test_name_valid_underscore_preserved_with_flag(): + with temporary_feature_override(FeatureName.SNAKE_CASE_SKILL_NAME, True): + fm = models.Frontmatter(name="my_skill", description="desc") + assert fm.name == "my_skill" + + def test_name_invalid_chars_ampersand(): - with pytest.raises(ValidationError, match="lowercase kebab-case"): + with pytest.raises( + ValidationError, match="name must be lowercase kebab-case" + ): models.Frontmatter(name="skill&name", description="desc") +def test_name_mixed_delimiters_rejected_by_default(): + with pytest.raises( + ValidationError, match="name must be lowercase kebab-case" + ): + models.Frontmatter(name="my-skill_1", description="desc") + + +def test_name_mixed_delimiters_rejected_with_flag(): + with temporary_feature_override(FeatureName.SNAKE_CASE_SKILL_NAME, True): + with pytest.raises( + ValidationError, match="Mixing hyphens and underscores is not allowed" + ): + models.Frontmatter(name="my-skill_1", description="desc") + + def test_name_valid_passes(): fm = models.Frontmatter(name="my-skill-2", description="desc") assert fm.name == "my-skill-2"