Skip to content
Merged
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
58 changes: 58 additions & 0 deletions integration_tests/_contract_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,61 @@ def _merge_public_class_contracts(
return result


def _validate_voice_public_class_contract_policy(
release_policy: SubmoduleExportPolicy,
agents_module: Any | None,
) -> None:
voice_class_exports: list[tuple[Mapping[str, str], type[Any]]] = []
for entry in release_policy.canonical_imports:
if entry["module"] != "agents.voice":
continue
canonical_module = _import_contract_module(entry["canonical_module"], agents_module)
value = getattr(canonical_module, entry["canonical_name"], None)
if isinstance(value, type):
voice_class_exports.append((entry, value))

abstract_bases = {
class_value for _, class_value in voice_class_exports if inspect.isabstract(class_value)
}
policy_by_identity = {
(entry["module"], entry["class_name"]): entry
for entry in release_policy.public_class_contracts
}
missing_entries: list[dict[str, object]] = []
for canonical_import, class_value in voice_class_exports:
is_abstract = inspect.isabstract(class_value)
if not is_abstract and not any(
issubclass(class_value, abstract_base) for abstract_base in abstract_bases
):
continue

identity = (
canonical_import["canonical_module"],
canonical_import["canonical_name"],
)
policy_entry = policy_by_identity.get(identity)
has_explicit_state = policy_entry is not None and (
(is_abstract and ("abstract" in policy_entry or "abstract_members" in policy_entry))
or (not is_abstract and policy_entry.get("abstract") is False)
Comment thread
seratch marked this conversation as resolved.
)
if not has_explicit_state:
missing_entries.append(
{
"abstract": is_abstract,
"class_name": canonical_import["canonical_name"],
"module": canonical_import["canonical_module"],
}
)

if missing_entries:
raise ValueError(
"Cannot promote the public Voice API without explicit public_class_contracts "
"coverage for its abstract bases and concrete implementations. Add or correct "
f"these policy entries: {missing_entries!r}. Required classes are derived from "
"canonical agents.voice imports and their public abstract-base relationships."
)


def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]:
if "class_name" in entry:
return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"]))
Expand Down Expand Up @@ -1493,6 +1548,9 @@ def build_released_api_contract(
) -> dict[str, Any]:
"""Build the next rolling release contract from the current public surface."""
agents = agents_module or importlib.import_module("agents")
if release_policy is not None:
_validate_voice_public_class_contract_policy(release_policy, agents_module)

compatibility_errors = validate_released_api_contract(contract, agents_module=agents)
if compatibility_errors:
details = "\n".join(f"- {error}" for error in compatibility_errors)
Expand Down
10 changes: 10 additions & 0 deletions tests/fixtures/released_api_contract_policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,11 @@
"class_name": "VoiceModelProvider",
"module": "agents.voice.model"
},
{
"abstract": false,
"class_name": "OpenAIVoiceModelProvider",
"module": "agents.voice.models.openai_model_provider"
},
{
"abstract": false,
"class_name": "OpenAISTTModel",
Expand All @@ -627,6 +632,11 @@
],
"class_name": "VoiceWorkflowBase",
"module": "agents.voice.workflow"
},
{
"abstract": false,
"class_name": "SingleAgentVoiceWorkflow",
"module": "agents.voice.workflow"
}
],
"public_properties": [
Expand Down
128 changes: 127 additions & 1 deletion tests/test_released_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import subprocess
import sys
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, replace
from enum import Enum
from importlib.metadata import version
from inspect import Parameter, Signature
Expand Down Expand Up @@ -2592,6 +2592,121 @@ class PublicState(TypedDict, total=False):
assert updated["callables"]["agents.submodule.NewPublic"] == _callable_contract(NewPublic)


def test_release_contract_promotion_rejects_missing_voice_concrete_state_policy() -> None:
contract = load_api_contract(CONTRACT)
policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json"))
omitted_classes = {"OpenAIVoiceModelProvider", "SingleAgentVoiceWorkflow"}
incomplete_policy = replace(
policy,
public_class_contracts=tuple(
entry
for entry in policy.public_class_contracts
if entry["class_name"] not in omitted_classes
),
)

with pytest.raises(ValueError) as exc_info:
build_released_api_contract(
contract,
baseline=contract["baseline"],
baseline_commit=contract["baseline_commit"],
release_policy=incomplete_policy,
)

message = str(exc_info.value)
assert "Cannot promote the public Voice API" in message
assert "OpenAIVoiceModelProvider" in message
assert "SingleAgentVoiceWorkflow" in message
assert "abstract': False" in message
assert "canonical agents.voice imports" in message


def test_release_contract_promotion_rejects_new_public_voice_implementation_without_state_policy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class PublicVoiceBase(abc.ABC):
@abc.abstractmethod
def run(self) -> None:
pass

class NewPublicVoiceImplementation(PublicVoiceBase):
def run(self) -> None:
pass

class UnrelatedPublicClass:
pass

agents_module = SimpleNamespace(__all__=[])
modules = {
"agents.voice.base": SimpleNamespace(PublicVoiceBase=PublicVoiceBase),
"agents.voice.implementation": SimpleNamespace(
NewPublicVoiceImplementation=NewPublicVoiceImplementation,
UnrelatedPublicClass=UnrelatedPublicClass,
),
}
monkeypatch.setattr(
contract_support,
"_import_contract_module",
lambda module_name, _agents_module: modules[module_name],
)
contract: dict[str, Any] = {
"baseline": "v0.22.0",
"baseline_commit": "a" * 40,
"required_top_level_exports": [],
"public_modules": ["agents"],
"canonical_imports": [],
"public_class_contracts": [],
"public_properties": [],
"public_type_aliases": [],
"public_typed_dicts": [],
"callables": {},
}
canonical_imports = (
{
"canonical_module": "agents.voice.base",
"canonical_name": "PublicVoiceBase",
"module": "agents.voice",
"name": "PublicVoiceBase",
},
{
"canonical_module": "agents.voice.implementation",
"canonical_name": "NewPublicVoiceImplementation",
"module": "agents.voice",
"name": "NewPublicVoiceImplementation",
},
{
"canonical_module": "agents.voice.implementation",
"canonical_name": "UnrelatedPublicClass",
"module": "agents.voice",
"name": "UnrelatedPublicClass",
},
)
release_policy = _release_policy(
{},
canonical_imports=canonical_imports,
public_class_contracts=(
{
"abstract_members": ["run"],
"class_name": "PublicVoiceBase",
"module": "agents.voice.base",
},
),
)

with pytest.raises(ValueError) as exc_info:
build_released_api_contract(
contract,
baseline="v0.22.1",
baseline_commit="b" * 40,
agents_module=agents_module,
release_policy=release_policy,
)

message = str(exc_info.value)
assert "NewPublicVoiceImplementation" in message
assert "UnrelatedPublicClass" not in message


def test_typed_dict_only_promotion_updates_baseline_commit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down Expand Up @@ -3529,6 +3644,7 @@ def test_repository_release_policy_declares_public_optional_modules() -> None:

def test_repository_release_policy_declares_public_state_surfaces() -> None:
policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json"))
contract_support._validate_voice_public_class_contract_policy(policy, None)
expected_modules = {
"agents.realtime.testing",
"agents.testing",
Expand Down Expand Up @@ -3637,6 +3753,11 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None:
"module": "agents.voice.model",
"abstract_members": ["get_stt_model", "get_tts_model"],
},
{
"abstract": False,
"class_name": "OpenAIVoiceModelProvider",
"module": "agents.voice.models.openai_model_provider",
},
{
"abstract": False,
"class_name": "OpenAISTTModel",
Expand All @@ -3657,6 +3778,11 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None:
"module": "agents.voice.workflow",
"abstract_members": ["run"],
},
{
"abstract": False,
"class_name": "SingleAgentVoiceWorkflow",
"module": "agents.voice.workflow",
},
)
assert policy.public_type_aliases == (
{
Expand Down
Loading