diff --git a/src/google/adk/workflow/_base_node.py b/src/google/adk/workflow/_base_node.py index f44fafdc..0c16a045 100644 --- a/src/google/adk/workflow/_base_node.py +++ b/src/google/adk/workflow/_base_node.py @@ -14,6 +14,7 @@ from __future__ import annotations +import abc from collections.abc import AsyncGenerator from typing import Any from typing import final @@ -33,7 +34,13 @@ if TYPE_CHECKING: from ..events.event import Event -class BaseNode(BaseModel): +# The `abc.ABC` base is load-bearing and must not be dropped. Subclasses +# declare `@abc.abstractmethod` without inheriting `abc.ABC` themselves, relying +# on this class for the metaclass. Static type checkers do not see `ABCMeta` +# through pydantic's `ModelMetaclass`, so without an explicit base they treat +# those subclasses as concrete and report the abstract methods as returning +# `None`. +class BaseNode(BaseModel, abc.ABC): """A base class for all nodes in the workflow graph.""" model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/tests/unittests/agents/test_base_agent.py b/tests/unittests/agents/test_base_agent.py index 024af34d..b889e3dd 100644 --- a/tests/unittests/agents/test_base_agent.py +++ b/tests/unittests/agents/test_base_agent.py @@ -14,6 +14,7 @@ """Testings for the BaseAgent.""" +import abc from enum import Enum from functools import partial import logging @@ -27,11 +28,13 @@ from google.adk.agents.base_agent import BaseAgent from google.adk.agents.base_agent import BaseAgentState from google.adk.agents.callback_context import CallbackContext from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.llm_agent import LlmAgent from google.adk.apps.app import ResumabilityConfig from google.adk.events.event import Event from google.adk.plugins.base_plugin import BasePlugin from google.adk.plugins.plugin_manager import PluginManager from google.adk.sessions.in_memory_session_service import InMemorySessionService +from google.adk.workflow import BaseNode from google.genai import types import pytest import pytest_mock @@ -973,6 +976,32 @@ def test_validate_sub_agents_unique_names_empty_list( assert len(parent.sub_agents) == 0 +@pytest.mark.parametrize('agent_class', [BaseNode, BaseAgent, LlmAgent]) +def test_agent_classes_are_abstract(agent_class: type[BaseNode]): + """Each class reaches `abc.ABC` through an explicitly declared base. + + Subclasses declare `@abc.abstractmethod` without inheriting `abc.ABC` + themselves. Static type checkers only honor that when a base says `abc.ABC` + in source, because they do not see `ABCMeta` through pydantic's + `ModelMetaclass`. Losing it makes those subclasses look concrete and their + abstract methods look like they return `None`. + """ + assert issubclass(agent_class, abc.ABC) + + +def test_abstract_subclass_cannot_be_instantiated(): + """A subclass that leaves an abstract method unimplemented still raises.""" + + class _Incomplete(LlmAgent): + + @abc.abstractmethod + def summarize(self) -> str: + """Left unimplemented on purpose.""" + + with pytest.raises(TypeError, match='abstract'): + _Incomplete(name='incomplete') + + if __name__ == '__main__': pytest.main([__file__])