Skip to content

Commit 23ad40b

Browse files
xuanyang15copybara-github
authored andcommitted
feat: Introduce a feature registry system for ADK
Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 832050198
1 parent b8e4aed commit 23ad40b

File tree

3 files changed

+338
-0
lines changed

3 files changed

+338
-0
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass
18+
from enum import Enum
19+
import warnings
20+
21+
from ..utils.env_utils import is_env_enabled
22+
23+
24+
class FeatureName(str, Enum):
25+
"""Feature names."""
26+
27+
JSON_SCHEMA_FOR_FUNC_DECL = "JSON_SCHEMA_FOR_FUNC_DECL"
28+
COMPUTER_USE = "COMPUTER_USE"
29+
30+
31+
class FeatureStage(Enum):
32+
"""Feature lifecycle stages.
33+
34+
Attributes:
35+
WIP: Work in progress, not functioning completely. ADK internal development
36+
only.
37+
EXPERIMENTAL: Feature works but API may change.
38+
STABLE: Production-ready, no breaking changes without MAJOR version bump.
39+
"""
40+
41+
WIP = "wip"
42+
EXPERIMENTAL = "experimental"
43+
STABLE = "stable"
44+
45+
46+
@dataclass
47+
class FeatureConfig:
48+
"""Feature configuration.
49+
50+
Attributes:
51+
stage: The feature stage.
52+
default_on: Whether the feature is enabled by default.
53+
"""
54+
55+
stage: FeatureStage
56+
default_on: bool = False
57+
58+
59+
# Central registry: FeatureName -> FeatureConfig
60+
_FEATURE_REGISTRY: dict[FeatureName, FeatureConfig] = {
61+
FeatureName.JSON_SCHEMA_FOR_FUNC_DECL: FeatureConfig(
62+
FeatureStage.WIP, default_on=False
63+
),
64+
FeatureName.COMPUTER_USE: FeatureConfig(
65+
FeatureStage.EXPERIMENTAL, default_on=True
66+
),
67+
}
68+
69+
# Track which experimental features have already warned (warn only once)
70+
_WARNED_FEATURES: set[FeatureName] = set()
71+
72+
73+
def _get_feature_config(
74+
feature_name: FeatureName,
75+
) -> FeatureConfig | None:
76+
"""Get the stage of a feature from the registry.
77+
78+
Args:
79+
feature_name: The feature name.
80+
81+
Returns:
82+
The feature config from the registry, or None if not found.
83+
"""
84+
return _FEATURE_REGISTRY.get(feature_name, None)
85+
86+
87+
def _register_feature(
88+
feature_name: FeatureName,
89+
config: FeatureConfig,
90+
) -> None:
91+
"""Register a feature with a specific config.
92+
93+
Args:
94+
feature_name: The feature name.
95+
config: The feature config to register.
96+
"""
97+
_FEATURE_REGISTRY[feature_name] = config
98+
99+
100+
def is_feature_enabled(feature_name: FeatureName) -> bool:
101+
"""Check if a feature is enabled at runtime.
102+
103+
This function is used for runtime behavior gating within stable features.
104+
It allows you to conditionally enable new behavior based on feature flags.
105+
106+
Args:
107+
feature_name: The feature name (e.g., FeatureName.RESUMABILITY).
108+
109+
Returns:
110+
True if the feature is enabled, False otherwise.
111+
112+
Example:
113+
```python
114+
def _execute_agent_loop():
115+
if is_feature_enabled(FeatureName.RESUMABILITY):
116+
# New behavior: save checkpoints for resuming
117+
return _execute_with_checkpoints()
118+
else:
119+
# Old behavior: run without checkpointing
120+
return _execute_standard()
121+
```
122+
"""
123+
config = _get_feature_config(feature_name)
124+
if config is None:
125+
raise ValueError(f"Feature {feature_name} is not registered.")
126+
127+
# Check environment variables first (highest priority)
128+
enable_var = f"ADK_ENABLE_{feature_name}"
129+
disable_var = f"ADK_DISABLE_{feature_name}"
130+
if is_env_enabled(enable_var):
131+
if config.stage != FeatureStage.STABLE:
132+
_emit_non_stable_warning_once(feature_name, config.stage)
133+
return True
134+
if is_env_enabled(disable_var):
135+
return False
136+
137+
# Fall back to registry config
138+
if config.stage != FeatureStage.STABLE and config.default_on:
139+
_emit_non_stable_warning_once(feature_name, config.stage)
140+
return config.default_on
141+
142+
143+
def _emit_non_stable_warning_once(
144+
feature_name: FeatureName,
145+
feature_stage: FeatureStage,
146+
) -> None:
147+
"""Emit a warning for a non-stable feature, but only once per feature.
148+
149+
Args:
150+
feature_name: The feature name.
151+
feature_stage: The feature stage.
152+
"""
153+
if feature_name not in _WARNED_FEATURES:
154+
_WARNED_FEATURES.add(feature_name)
155+
full_message = (
156+
f"[{feature_stage.name.upper()}] feature {feature_name} is enabled."
157+
)
158+
warnings.warn(full_message, category=UserWarning, stacklevel=4)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import os
18+
import warnings
19+
20+
from google.adk.features.feature_registry import _FEATURE_REGISTRY
21+
from google.adk.features.feature_registry import _get_feature_config
22+
from google.adk.features.feature_registry import _register_feature
23+
from google.adk.features.feature_registry import _WARNED_FEATURES
24+
from google.adk.features.feature_registry import FeatureConfig
25+
from google.adk.features.feature_registry import FeatureStage
26+
from google.adk.features.feature_registry import is_feature_enabled
27+
import pytest
28+
29+
FEATURE_CONFIG_WIP = FeatureConfig(FeatureStage.WIP, default_on=False)
30+
FEATURE_CONFIG_EXPERIMENTAL_DISABLED = FeatureConfig(
31+
FeatureStage.EXPERIMENTAL, default_on=False
32+
)
33+
FEATURE_CONFIG_EXPERIMENTAL_ENABLED = FeatureConfig(
34+
FeatureStage.EXPERIMENTAL, default_on=True
35+
)
36+
FEATURE_CONFIG_STABLE = FeatureConfig(FeatureStage.STABLE, default_on=True)
37+
38+
39+
@pytest.fixture(autouse=True)
40+
def reset_env_and_registry(monkeypatch):
41+
"""Reset environment variables and registry before each test."""
42+
# Clean up environment variables
43+
for key in list(os.environ.keys()):
44+
if key.startswith("ADK_ENABLE_") or key.startswith("ADK_DISABLE_"):
45+
monkeypatch.delenv(key, raising=False)
46+
47+
# Clear registry (but keep it as a dict for adding test entries)
48+
_FEATURE_REGISTRY.clear()
49+
50+
# Reset warned features set
51+
_WARNED_FEATURES.clear()
52+
53+
yield
54+
55+
# Clean up after test
56+
_FEATURE_REGISTRY.clear()
57+
58+
# Reset warned features set
59+
_WARNED_FEATURES.clear()
60+
61+
62+
class TestGetFeatureConfig:
63+
"""Tests for get_feature_config() function."""
64+
65+
def test_feature_in_registry(self):
66+
"""Returns correct config for features in registry."""
67+
_register_feature("MY_FEATURE", FEATURE_CONFIG_EXPERIMENTAL_ENABLED)
68+
assert (
69+
_get_feature_config("MY_FEATURE") == FEATURE_CONFIG_EXPERIMENTAL_ENABLED
70+
)
71+
72+
def test_feature_not_in_registry(self):
73+
"""Returns EXPERIMENTAL_DISABLED for features not in registry."""
74+
assert _get_feature_config("UNKNOWN_FEATURE") is None
75+
76+
77+
class TestIsFeatureEnabled:
78+
"""Tests for is_feature_enabled() runtime check function."""
79+
80+
def test_not_in_registry_raises_value_error(self):
81+
"""Features not in registry raise ValueError when checked."""
82+
with pytest.raises(ValueError):
83+
is_feature_enabled("NEW_FEATURE")
84+
85+
def test_wip_feature_disabled(self):
86+
"""WIP features are disabled by default."""
87+
_register_feature("WIP_FEATURE", FEATURE_CONFIG_WIP)
88+
with warnings.catch_warnings(record=True) as w:
89+
assert not is_feature_enabled("WIP_FEATURE")
90+
assert not w
91+
92+
def test_wip_feature_enabled(self):
93+
"""WIP features are disabled by default."""
94+
_register_feature(
95+
"WIP_FEATURE", FeatureConfig(FeatureStage.WIP, default_on=True)
96+
)
97+
with warnings.catch_warnings(record=True) as w:
98+
assert is_feature_enabled("WIP_FEATURE")
99+
assert len(w) == 1
100+
assert "[WIP] feature WIP_FEATURE is enabled." in str(w[0].message)
101+
102+
def test_experimental_disabled_feature(self):
103+
"""Experimental disabled features are disabled."""
104+
_register_feature("EXP_DISABLED", FEATURE_CONFIG_EXPERIMENTAL_DISABLED)
105+
with warnings.catch_warnings(record=True) as w:
106+
assert not is_feature_enabled("EXP_DISABLED")
107+
assert not w
108+
109+
def test_experimental_enabled_feature(self):
110+
"""Experimental enabled features are enabled."""
111+
_register_feature("EXP_ENABLED", FEATURE_CONFIG_EXPERIMENTAL_ENABLED)
112+
with warnings.catch_warnings(record=True) as w:
113+
assert is_feature_enabled("EXP_ENABLED")
114+
assert len(w) == 1
115+
assert "[EXPERIMENTAL] feature EXP_ENABLED is enabled." in str(
116+
w[0].message
117+
)
118+
119+
def test_stable_feature_enabled(self):
120+
"""Stable features are enabled."""
121+
_register_feature("STABLE_FEATURE", FEATURE_CONFIG_STABLE)
122+
with warnings.catch_warnings(record=True) as w:
123+
assert is_feature_enabled("STABLE_FEATURE")
124+
assert not w
125+
126+
def test_enable_env_var_takes_precedence(self, monkeypatch):
127+
"""ADK_ENABLE_<FEATURE> takes precedence over registry."""
128+
# Feature disabled in registry
129+
_register_feature("DISABLED_FEATURE", FEATURE_CONFIG_EXPERIMENTAL_DISABLED)
130+
131+
# But enabled via env var
132+
monkeypatch.setenv("ADK_ENABLE_DISABLED_FEATURE", "true")
133+
134+
with warnings.catch_warnings(record=True) as w:
135+
assert is_feature_enabled("DISABLED_FEATURE")
136+
assert len(w) == 1
137+
assert "[EXPERIMENTAL] feature DISABLED_FEATURE is enabled." in str(
138+
w[0].message
139+
)
140+
141+
def test_disable_env_var_takes_precedence(self, monkeypatch):
142+
"""ADK_DISABLE_<FEATURE> takes precedence over registry."""
143+
# Feature enabled in registry
144+
_register_feature("ENABLED_FEATURE", FEATURE_CONFIG_STABLE)
145+
146+
# But disabled via env var
147+
monkeypatch.setenv("ADK_DISABLE_ENABLED_FEATURE", "true")
148+
149+
with warnings.catch_warnings(record=True) as w:
150+
assert not is_feature_enabled("ENABLED_FEATURE")
151+
assert not w
152+
153+
def test_warn_once_per_feature(self, monkeypatch):
154+
"""Warn once per feature, even if being used multiple times."""
155+
# Feature disabled in registry
156+
_register_feature("DISABLED_FEATURE", FEATURE_CONFIG_EXPERIMENTAL_DISABLED)
157+
158+
# But enabled via env var
159+
monkeypatch.setenv("ADK_ENABLE_DISABLED_FEATURE", "true")
160+
161+
with warnings.catch_warnings(record=True) as w:
162+
is_feature_enabled("DISABLED_FEATURE")
163+
is_feature_enabled("DISABLED_FEATURE")
164+
assert len(w) == 1
165+
assert "[EXPERIMENTAL] feature DISABLED_FEATURE is enabled." in str(
166+
w[0].message
167+
)

0 commit comments

Comments
 (0)