From 2235fece22bf02cf583569ba0382f9d6e9ae2390 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 29 Mar 2026 08:37:17 +0000 Subject: [PATCH] feat(resource): wire inheritance polymorphism into tool binding and auto-discovery Refactor tool binding to use polymorphic resource type matching via is_subtype_of() instead of exact string comparison or static parent_types lists. A tool declared for resource type 'code' now automatically binds to 'code/python', 'code/rust', etc. Resource auto-discovery gains discover_most_specific_type() to resolve the deepest subtype in the hierarchy, and get_type_tree() enables the new 'resource type tree' CLI command to visualise the full inheritance hierarchy. Plugins can now register custom resource types at runtime via register_plugin_resource_type() on the registry service and register_resource_types() on the plugin manager, extending the hierarchy without code changes. Resource metadata output includes the full inheritance chain for debugging (inheritance_chain field in JSON/YAML output). Key changes: - BindingResolutionService._is_type_compatible(): delegates to is_subtype_of() for polymorphic matching (ADR-042) - ResourceRegistryService: add discover_most_specific_type(), get_type_tree(), register_plugin_resource_type(), and get_resource_inheritance_metadata() - PluginManager: add register_resource_types() extension point - CLI: add 'resource type tree' command and inheritance_chain in resource metadata output - 14 BDD scenarios covering polymorphic binding, auto-discovery, tree hierarchy, plugin registration, and metadata chain ISSUES CLOSED: #942 --- .../resource_inheritance_wiring_942.feature | 97 +++++ features/steps/binding_resolution_steps.py | 8 + .../resource_inheritance_wiring_942_steps.py | 412 ++++++++++++++++++ .../services/binding_resolution_service.py | 21 +- .../services/resource_registry_service.py | 178 ++++++++ src/cleveragents/cli/commands/resource.py | 72 ++- .../infrastructure/plugins/manager.py | 78 ++++ vulture_whitelist.py | 8 + 8 files changed, 870 insertions(+), 4 deletions(-) create mode 100644 features/resource_inheritance_wiring_942.feature create mode 100644 features/steps/resource_inheritance_wiring_942_steps.py diff --git a/features/resource_inheritance_wiring_942.feature b/features/resource_inheritance_wiring_942.feature new file mode 100644 index 000000000..ff1328208 --- /dev/null +++ b/features/resource_inheritance_wiring_942.feature @@ -0,0 +1,97 @@ +Feature: Wire inheritance polymorphism into tool binding and auto-discovery + As a developer + I want resource type inheritance wired into tool binding and auto-discovery + So that polymorphic matching works across the resource system + + # ── Polymorphic tool binding ────────────────────────────────────────────── + + Scenario: Tool bound to parent type matches child resource type + Given a registry with type hierarchy "code" -> "code/python" for wiring + And a binding service using that registry for wiring + When I check type compatibility of "code/python" against required "code" for wiring + Then the types should be compatible for wiring + + Scenario: Tool bound to parent type matches grandchild resource type + Given a registry with type hierarchy "file" -> "code" -> "code/python" for wiring + And a binding service using that registry for wiring + When I check type compatibility of "code/python" against required "file" for wiring + Then the types should be compatible for wiring + + Scenario: Tool bound to child type does not match parent resource type + Given a registry with type hierarchy "code" -> "code/python" for wiring + And a binding service using that registry for wiring + When I check type compatibility of "code" against required "code/python" for wiring + Then the types should NOT be compatible for wiring + + Scenario: Exact type match still works + Given a registry with type hierarchy "code" -> "code/python" for wiring + And a binding service using that registry for wiring + When I check type compatibility of "code/python" against required "code/python" for wiring + Then the types should be compatible for wiring + + Scenario: Unrelated types are not compatible + Given a registry with type hierarchy "code" -> "code/python" for wiring + And a binding service using that registry for wiring + When I check type compatibility of "code/python" against required "data" for wiring + Then the types should NOT be compatible for wiring + + # ── Resource auto-discovery: most specific type ─────────────────────────── + + Scenario: discover_most_specific_type picks deepest subtype + Given a registry service with hierarchy "file" -> "code" -> "code/python" for wiring + When I call discover_most_specific_type with candidates "file", "code", "code/python" for wiring + Then the most specific type should be "code/python" for wiring + + Scenario: discover_most_specific_type with single candidate + Given a registry service with hierarchy "file" -> "code" -> "code/python" for wiring + When I call discover_most_specific_type with candidates "code" for wiring + Then the most specific type should be "code" for wiring + + Scenario: discover_most_specific_type with unrelated types keeps first + Given a registry service with hierarchy "file" -> "code" -> "code/python" for wiring + When I call discover_most_specific_type with candidates "code/python", "data" for wiring + Then the most specific type should be "code/python" for wiring + + Scenario: discover_most_specific_type rejects empty list + Given a registry service with hierarchy "file" -> "code" -> "code/python" for wiring + When I call discover_most_specific_type with empty candidates for wiring + Then a ValueError should be raised for wiring + + # ── Type hierarchy tree ─────────────────────────────────────────────────── + + Scenario: get_type_tree returns correct hierarchy structure + Given a registry service with hierarchy "file" -> "code" -> "code/python" for wiring + When I call get_type_tree for wiring + Then the tree should have "file" as a root type for wiring + And the tree should show "code" as a child of "file" for wiring + And the tree should show "code/python" as a child of "code" for wiring + + # ── Plugin resource type registration ───────────────────────────────────── + + Scenario: Plugin registers a custom resource type extending the hierarchy + Given a registry service with hierarchy "file" -> "code" for wiring + When I register a plugin resource type "acme/code-rust" inheriting "code" for wiring + Then the type "acme/code-rust" should be registered for wiring + And "acme/code-rust" should be a subtype of "code" for wiring + + Scenario: Plugin registration rejects type with missing parent + Given a registry service with hierarchy "file" -> "code" for wiring + When I register a plugin resource type "acme/orphan" inheriting "nonexistent" for wiring + Then a ValidationError should be raised for plugin registration for wiring + + # ── Inheritance chain in resource metadata ──────────────────────────────── + + Scenario: Resource metadata includes inheritance chain + Given a registry service with hierarchy "file" -> "code" -> "code/python" for wiring + And a resource "test/my-py" of type "code/python" for wiring + When I get the resource inheritance metadata for "test/my-py" for wiring + Then the inheritance chain should be "code/python", "code", "file" for wiring + + # ── Plugin manager resource type extension point ────────────────────────── + + Scenario: Plugin manager registers resource types from activated plugin + Given a registry service with hierarchy "file" -> "code" for wiring + And a plugin manager for wiring + And a mock plugin "test-plugin" with resource types for wiring + When I call register_resource_types on the plugin manager for wiring + Then the plugin's resource types should be registered for wiring diff --git a/features/steps/binding_resolution_steps.py b/features/steps/binding_resolution_steps.py index f6c0f8e53..5fce02dc3 100644 --- a/features/steps/binding_resolution_steps.py +++ b/features/steps/binding_resolution_steps.py @@ -106,6 +106,14 @@ def step_mock_registry(context: Context) -> None: registry.show_resource = show_resource registry.show_type = show_type + # Remove auto-generated MagicMock attributes that interfere with + # BindingResolutionService._is_type_compatible() — it checks + # hasattr(registry, "is_subtype_of") and MagicMock auto-creates + # any attribute, returning a truthy mock that bypasses the + # parent_types fallback path. Delete these so the service falls + # through to the show_type + parent_types code path. + del registry.is_subtype_of + del registry._load_type_registry context.mock_registry = registry diff --git a/features/steps/resource_inheritance_wiring_942_steps.py b/features/steps/resource_inheritance_wiring_942_steps.py new file mode 100644 index 000000000..71dcfc2f0 --- /dev/null +++ b/features/steps/resource_inheritance_wiring_942_steps.py @@ -0,0 +1,412 @@ +"""Steps for resource_inheritance_wiring_942.feature — polymorphic tool binding and auto-discovery.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from behave import given, then, when # type: ignore[import-untyped] +from behave.runner import Context # type: ignore[import-untyped] +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from cleveragents.application.services._resource_registry_data import spec_to_db +from cleveragents.application.services.binding_resolution_service import ( + BindingResolutionService, +) +from cleveragents.application.services.resource_registry_service import ( + ResourceRegistryService, +) +from cleveragents.core.exceptions import ValidationError +from cleveragents.domain.models.core.resource_type import ResourceTypeSpec +from cleveragents.infrastructure.database.models import ( + Base, + ResourceModel, + ResourceTypeModel, +) +from cleveragents.infrastructure.plugins.manager import PluginManager +from cleveragents.infrastructure.plugins.types import ( + PluginDescriptor, + PluginState, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Prefix all test type names to avoid collisions with built-in types +# that the service bootstraps automatically. +_PFX = "w942-" + + +def _make_service() -> ResourceRegistryService: + """Create a ResourceRegistryService backed by an in-memory SQLite DB.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + return ResourceRegistryService(session_factory=factory) + + +def _register_type( + svc: ResourceRegistryService, + name: str, + *, + inherits: str | None = None, +) -> None: + """Register a minimal test type using spec_to_db for correct column mapping.""" + session = svc._session() + try: + existing = session.query(ResourceTypeModel).filter_by(name=name).first() + if existing is not None: + return + spec = ResourceTypeSpec.from_config( + { + "name": name, + "description": f"Test type {name}", + "resource_kind": "physical", + "sandbox_strategy": "none", + "user_addable": True, + "built_in": True, + "inherits": inherits, + } + ) + session.add(spec_to_db(spec, source="test")) + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def _register_resource( + svc: ResourceRegistryService, + name: str, + type_name: str, +) -> None: + """Register a minimal resource instance.""" + from datetime import UTC, datetime + + from ulid import ULID + + session = svc._session() + try: + now_iso = datetime.now(tz=UTC).isoformat() + row = ResourceModel( + resource_id=str(ULID()), + namespaced_name=name, + namespace=name.split("/", 1)[0] if "/" in name else None, + type_name=type_name, + resource_kind="physical", + location=None, + description=None, + read_only=False, + auto_discovered=False, + sandbox_strategy=None, + content_hash=None, + properties_json=None, + metadata_json=None, + created_at=now_iso, + updated_at=now_iso, + ) + session.add(row) + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +# --------------------------------------------------------------------------- +# Given steps — type hierarchy setup +# --------------------------------------------------------------------------- + + +@given('a registry with type hierarchy "code" -> "code/python" for wiring') +def step_registry_code_python(context: Context) -> None: + svc = _make_service() + _register_type(svc, f"{_PFX}code") + _register_type(svc, f"{_PFX}code-python", inherits=f"{_PFX}code") + _register_type(svc, f"{_PFX}data") + context.wiring_svc = svc + # Alias mapping for use in When/Then steps + context.wiring_type_map = { + "code": f"{_PFX}code", + "code/python": f"{_PFX}code-python", + "data": f"{_PFX}data", + } + + +@given('a registry with type hierarchy "file" -> "code" -> "code/python" for wiring') +def step_registry_file_code_python(context: Context) -> None: + svc = _make_service() + _register_type(svc, f"{_PFX}file") + _register_type(svc, f"{_PFX}code", inherits=f"{_PFX}file") + _register_type(svc, f"{_PFX}code-python", inherits=f"{_PFX}code") + _register_type(svc, f"{_PFX}data") + context.wiring_svc = svc + context.wiring_type_map = { + "file": f"{_PFX}file", + "code": f"{_PFX}code", + "code/python": f"{_PFX}code-python", + "data": f"{_PFX}data", + } + + +@given("a binding service using that registry for wiring") +def step_binding_service(context: Context) -> None: + context.wiring_binding_svc = BindingResolutionService( + resource_registry=context.wiring_svc, + ) + + +@given('a registry service with hierarchy "file" -> "code" -> "code/python" for wiring') +def step_registry_service_hierarchy(context: Context) -> None: + svc = _make_service() + _register_type(svc, f"{_PFX}file") + _register_type(svc, f"{_PFX}code", inherits=f"{_PFX}file") + _register_type(svc, f"{_PFX}code-python", inherits=f"{_PFX}code") + _register_type(svc, f"{_PFX}data") + context.wiring_svc = svc + context.wiring_type_map = { + "file": f"{_PFX}file", + "code": f"{_PFX}code", + "code/python": f"{_PFX}code-python", + "data": f"{_PFX}data", + } + + +@given('a registry service with hierarchy "file" -> "code" for wiring') +def step_registry_service_file_code(context: Context) -> None: + svc = _make_service() + _register_type(svc, f"{_PFX}file") + _register_type(svc, f"{_PFX}code", inherits=f"{_PFX}file") + context.wiring_svc = svc + context.wiring_type_map = { + "file": f"{_PFX}file", + "code": f"{_PFX}code", + } + + +@given('a resource "test/my-py" of type "code/python" for wiring') +def step_register_resource(context: Context) -> None: + real_name = context.wiring_type_map["code/python"] + _register_resource(context.wiring_svc, "test/my-py", real_name) + + +@given("a plugin manager for wiring") +def step_plugin_manager(context: Context) -> None: + context.wiring_pm = PluginManager() + + +@given('a mock plugin "test-plugin" with resource types for wiring') +def step_mock_plugin(context: Context) -> None: + real_code = context.wiring_type_map["code"] + descriptor = PluginDescriptor( + name="test-plugin", + module_path="cleveragents.test_mock", + class_name="MockPlugin", + ) + pm: PluginManager = context.wiring_pm + with pm._lock: + pm._plugins["test-plugin"] = descriptor + descriptor.state = PluginState.ACTIVATED + + mock_instance = MagicMock() + mock_instance.resource_types = [ + { + "name": "acme/w942-test-type", + "description": "Plugin test type", + "resource_kind": "physical", + "sandbox_strategy": "none", + "user_addable": True, + "inherits": real_code, + }, + ] + pm._instances["test-plugin"] = mock_instance + + +# --------------------------------------------------------------------------- +# When steps +# --------------------------------------------------------------------------- + + +def _resolve_name(context: Context, name: str) -> str: + """Resolve a friendly name to its prefixed test name.""" + tmap = getattr(context, "wiring_type_map", {}) + return tmap.get(name, name) + + +@when( + 'I check type compatibility of "{actual}" against required "{required}" for wiring' +) +def step_check_compatibility( + context: Context, + actual: str, + required: str, +) -> None: + binding_svc: BindingResolutionService = context.wiring_binding_svc + real_actual = _resolve_name(context, actual) + real_required = _resolve_name(context, required) + context.wiring_compatible = binding_svc._is_type_compatible( + real_required, real_actual + ) + + +@when("I call discover_most_specific_type with candidates {candidates_str} for wiring") +def step_discover_most_specific( + context: Context, + candidates_str: str, +) -> None: + raw = [c.strip().strip('"') for c in candidates_str.split(",")] + candidates = [_resolve_name(context, c) for c in raw] + svc: ResourceRegistryService = context.wiring_svc + result = svc.discover_most_specific_type(candidates) + # Store the friendly name for assertion + reverse_map = {v: k for k, v in context.wiring_type_map.items()} + context.wiring_result = reverse_map.get(result, result) + + +@when("I call discover_most_specific_type with empty candidates for wiring") +def step_discover_most_specific_empty(context: Context) -> None: + svc: ResourceRegistryService = context.wiring_svc + context.wiring_error = None + try: + svc.discover_most_specific_type([]) + except ValueError as exc: + context.wiring_error = exc + + +@when("I call get_type_tree for wiring") +def step_get_type_tree(context: Context) -> None: + svc: ResourceRegistryService = context.wiring_svc + context.wiring_tree = svc.get_type_tree() + + +@when('I register a plugin resource type "{name}" inheriting "{parent}" for wiring') +def step_register_plugin_type( + context: Context, + name: str, + parent: str, +) -> None: + svc: ResourceRegistryService = context.wiring_svc + real_parent = _resolve_name(context, parent) + context.wiring_error = None + context.wiring_result = None + try: + spec = svc.register_plugin_resource_type( + { + "name": name, + "description": f"Plugin type {name}", + "resource_kind": "physical", + "sandbox_strategy": "none", + "user_addable": True, + "inherits": real_parent, + } + ) + context.wiring_result = spec + except (ValidationError, ValueError) as exc: + context.wiring_error = exc + + +@when('I get the resource inheritance metadata for "{name}" for wiring') +def step_get_inheritance_metadata(context: Context, name: str) -> None: + svc: ResourceRegistryService = context.wiring_svc + context.wiring_meta = svc.get_resource_inheritance_metadata(name) + + +@when("I call register_resource_types on the plugin manager for wiring") +def step_register_resource_types_pm(context: Context) -> None: + pm: PluginManager = context.wiring_pm + svc: ResourceRegistryService = context.wiring_svc + context.wiring_registered_types = pm.register_resource_types(svc, "test-plugin") + + +# --------------------------------------------------------------------------- +# Then steps +# --------------------------------------------------------------------------- + + +@then("the types should be compatible for wiring") +def step_types_compatible(context: Context) -> None: + assert context.wiring_compatible is True, "Expected types to be compatible" + + +@then("the types should NOT be compatible for wiring") +def step_types_not_compatible(context: Context) -> None: + assert context.wiring_compatible is False, "Expected types to NOT be compatible" + + +@then('the most specific type should be "{expected}" for wiring') +def step_most_specific(context: Context, expected: str) -> None: + assert context.wiring_result == expected, ( + f"Expected {expected}, got {context.wiring_result}" + ) + + +@then("a ValueError should be raised for wiring") +def step_value_error(context: Context) -> None: + assert context.wiring_error is not None, "Expected ValueError" + assert isinstance(context.wiring_error, ValueError) + + +@then('the tree should have "{name}" as a root type for wiring') +def step_tree_root(context: Context, name: str) -> None: + real_name = _resolve_name(context, name) + tree = context.wiring_tree + assert real_name in tree.get("(root)", []), ( + f"Expected {real_name} in root types, got {tree.get('(root)', [])}" + ) + + +@then('the tree should show "{child}" as a child of "{parent}" for wiring') +def step_tree_child_of_parent( + context: Context, + child: str, + parent: str, +) -> None: + real_child = _resolve_name(context, child) + real_parent = _resolve_name(context, parent) + tree = context.wiring_tree + assert real_child in tree.get(real_parent, []), ( + f"Expected {real_child} under {real_parent}, got {tree.get(real_parent, [])}" + ) + + +@then('the type "{name}" should be registered for wiring') +def step_type_registered(context: Context, name: str) -> None: + svc: ResourceRegistryService = context.wiring_svc + spec = svc.show_type(name) + assert spec is not None, f"Type {name} should be registered" + + +@then('"{child}" should be a subtype of "{parent}" for wiring') +def step_is_subtype(context: Context, child: str, parent: str) -> None: + real_parent = _resolve_name(context, parent) + svc: ResourceRegistryService = context.wiring_svc + assert svc.is_subtype_of(child, real_parent), ( + f"{child} should be a subtype of {real_parent}" + ) + + +@then("a ValidationError should be raised for plugin registration for wiring") +def step_validation_error(context: Context) -> None: + assert context.wiring_error is not None, "Expected ValidationError" + assert isinstance(context.wiring_error, (ValidationError, ValueError)) + + +@then("the inheritance chain should be {chain_str} for wiring") +def step_inheritance_chain(context: Context, chain_str: str) -> None: + friendly = [c.strip().strip('"') for c in chain_str.split(",")] + expected = [_resolve_name(context, c) for c in friendly] + meta = context.wiring_meta + assert meta["inheritance_chain"] == expected, ( + f"Expected chain {expected}, got {meta['inheritance_chain']}" + ) + + +@then("the plugin's resource types should be registered for wiring") +def step_plugin_types_registered(context: Context) -> None: + registered = context.wiring_registered_types + assert len(registered) > 0, "Expected at least one type registered" + assert "acme/w942-test-type" in registered diff --git a/src/cleveragents/application/services/binding_resolution_service.py b/src/cleveragents/application/services/binding_resolution_service.py index 3b37f8a45..18e0a71cc 100644 --- a/src/cleveragents/application/services/binding_resolution_service.py +++ b/src/cleveragents/application/services/binding_resolution_service.py @@ -49,6 +49,7 @@ from cleveragents.domain.models.core.tool import ( ResourceSlot, Tool, ) +from cleveragents.resource.inheritance import is_subtype_of logger = logging.getLogger(__name__) @@ -355,13 +356,27 @@ class BindingResolutionService: ) -> bool: """Check if *actual_type* satisfies *required_type*. - Exact match always passes. If *actual_type* declares - *required_type* in its ``parent_types`` hierarchy, the - check also passes (sub-type relationship). + Uses the inheritance hierarchy via ``is_subtype_of()`` for + polymorphic matching (ADR-042 §Tool Binding Polymorphism). + A tool bound to ``code`` automatically matches ``code/python``, + ``code/javascript``, etc. """ if actual_type == required_type: return True + # Use the registry service's is_subtype_of when available + # (delegates to the full inheritance hierarchy). + if hasattr(self._registry, "is_subtype_of"): + return self._registry.is_subtype_of(actual_type, required_type) + + # Fallback: use inheritance module directly with a loaded + # type registry when the registry service exposes + # _load_type_registry (e.g. in tests with mocks). + if hasattr(self._registry, "_load_type_registry"): + type_reg = self._registry._load_type_registry() + return is_subtype_of(actual_type, required_type, type_reg) + + # Last resort: check the static parent_types list on the spec. try: spec: ResourceTypeSpec = self._registry.show_type( actual_type, diff --git a/src/cleveragents/application/services/resource_registry_service.py b/src/cleveragents/application/services/resource_registry_service.py index 66fbb535f..567e08f90 100644 --- a/src/cleveragents/application/services/resource_registry_service.py +++ b/src/cleveragents/application/services/resource_registry_service.py @@ -458,6 +458,184 @@ class ResourceRegistryService(ResourceInstanceMixin, ResourceDagMixin): registry = self._load_type_registry() return is_subtype_of(type_name, ancestor_name, registry) + def discover_most_specific_type( + self, + candidate_types: list[str], + ) -> str: + """Resolve the most specific type from a list of candidates. + + Given a set of candidate type names (e.g. from MIME type or + extension mapping), returns the one that is the most specific + (deepest) in the inheritance hierarchy. If two candidates are + unrelated, the first one in the list wins. + + This enables auto-discovery to resolve ``.py`` files to + ``code/python`` instead of just ``file`` or ``code``. + + Args: + candidate_types: List of candidate type names, most generic + first. + + Returns: + The most specific (deepest) type name. + + Raises: + ValueError: If *candidate_types* is empty. + """ + if not candidate_types: + raise ValueError("candidate_types must not be empty") + + if len(candidate_types) == 1: + return candidate_types[0] + + registry = self._load_type_registry() + best = candidate_types[0] + + for candidate in candidate_types[1:]: + if candidate not in registry: + continue + # If candidate is a subtype of the current best, it's more + # specific. Replace best. + if is_subtype_of(candidate, best, registry): + best = candidate + # If best is a subtype of candidate, keep best (it's already + # more specific). Otherwise the two are unrelated — keep + # whichever appeared first. + + return best + + def get_type_tree(self) -> dict[str, list[str]]: + """Build a tree representation of the type hierarchy. + + Returns a dict mapping each type name to its list of direct + child type names. Root types (no parent) are children of a + synthetic ``"(root)"`` key. + + Returns: + Dict mapping parent type name to list of child type names. + """ + registry = self._load_type_registry() + tree: dict[str, list[str]] = {"(root)": []} + + for name, entry in registry.items(): + parent = entry.get("inherits") + if parent is None: + tree["(root)"].append(name) + else: + tree.setdefault(parent, []).append(name) + + # Sort children for deterministic output + for children in tree.values(): + children.sort() + + return tree + + def register_plugin_resource_type( + self, + type_config: dict[str, Any], + ) -> ResourceTypeSpec: + """Register a custom resource type from a plugin configuration dict. + + This is the extension point for plugins to add resource types to + the hierarchy at runtime. The config dict has the same schema as + a YAML resource type configuration file. + + Args: + type_config: Dict with resource type configuration (name, + description, resource_kind, sandbox_strategy, inherits, + etc.). + + Returns: + The validated ``ResourceTypeSpec``. + + Raises: + ValidationError: If the config is invalid or chain rules + are violated. + """ + spec = ResourceTypeSpec.from_config(type_config) + + session = self._session() + try: + existing = ( + session.query(ResourceTypeModel).filter_by(name=spec.name).first() + ) + if existing is not None: + raise ValidationError( + message=f"Resource type '{spec.name}' already exists", + details={"name": spec.name}, + ) + + if spec.inherits is not None: + registry = self._load_type_registry() + try: + validate_chain( + spec.name, + spec.inherits, + registry, + is_built_in=spec.built_in, + ) + except ( + ResourceTypeParentNotFoundError, + ValueError, + ) as exc: + raise ValidationError( + message=str(exc), + details={"name": spec.name, "inherits": spec.inherits}, + ) from exc + + db_model = spec_to_db(spec, source="plugin") + session.add(db_model) + session.flush() + session.commit() + except ValidationError: + session.rollback() + raise + except Exception: + session.rollback() + raise + finally: + session.close() + + return spec + + def get_resource_inheritance_metadata( + self, + resource_name_or_id: str, + ) -> dict[str, Any]: + """Return inheritance metadata for a resource instance. + + Resolves the resource, then returns its type name, the full + inheritance chain, and whether each ancestor is built-in. + + Args: + resource_name_or_id: Resource name or ULID. + + Returns: + Dict with ``type_name``, ``inheritance_chain``, and + ``chain_details`` keys. + + Raises: + NotFoundError: If the resource is not found. + """ + resource = self.show_resource(resource_name_or_id) + registry = self._load_type_registry() + chain = resolve_inheritance_chain(resource.resource_type_name, registry) + chain_details: list[dict[str, Any]] = [] + for type_name in chain: + entry = registry.get(type_name, {}) + chain_details.append( + { + "name": type_name, + "inherits": entry.get("inherits"), + "built_in": entry.get("built_in", False), + } + ) + return { + "type_name": resource.resource_type_name, + "inheritance_chain": chain, + "chain_details": chain_details, + } + def _load_type_registry(self) -> dict[str, dict[str, Any]]: """Load all registered types into an in-memory dict for chain ops. diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index e594ae3be..14f997442 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -146,7 +146,7 @@ def _resource_type_dict(spec: Any) -> dict[str, object]: def _resource_dict(resource: Any) -> dict[str, object]: """Convert a Resource domain object to a plain dict for output formatting.""" - return { + result: dict[str, object] = { "resource_id": resource.resource_id, "name": resource.name, "type": resource.resource_type_name, @@ -162,6 +162,19 @@ def _resource_dict(resource: Any) -> dict[str, object]: else str(resource.updated_at), } + # Include inheritance chain metadata for debugging + try: + service = _get_registry_service() + meta = service.get_resource_inheritance_metadata( + resource.resource_id, + ) + result["inheritance_chain"] = meta["inheritance_chain"] + except Exception: + # Gracefully degrade — inheritance metadata is optional + result["inheritance_chain"] = [resource.resource_type_name] + + return result + # --------------------------------------------------------------------------- # Resource Type commands @@ -351,6 +364,63 @@ def type_list( raise typer.Abort() from exc +@type_app.command("tree") +def type_tree( + fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """Display the resource type inheritance hierarchy as a tree. + + Shows all registered resource types organized by their inheritance + relationships. Root types (no parent) appear at the top level, with + child types indented below their parents. + + Examples: + agents resource type tree + agents resource type tree --format json + """ + try: + service = _get_registry_service() + tree = service.get_type_tree() + + if fmt != OutputFormat.RICH.value: + console.print(format_output(tree, fmt)) + return + + def _print_type_tree_node( + name: str, + tree_data: dict[str, list[str]], + indent: int = 0, + ) -> None: + prefix = " " * indent + if indent == 0: + console.print(f"{prefix}[bold cyan]{name}[/bold cyan]") + else: + console.print(f"{prefix}[cyan]{name}[/cyan]") + for child in tree_data.get(name, []): + _print_type_tree_node(child, tree_data, indent + 1) + + roots = tree.get("(root)", []) + if not roots: + console.print("[yellow]No resource types registered.[/yellow]") + return + + console.print("[bold]Resource Type Hierarchy[/bold]\n") + for root in roots: + _print_type_tree_node(root, tree) + + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + except Exception as exc: + if isinstance(exc, (typer.Abort, typer.Exit)): + raise + console.print(f"[red]Unexpected error:[/red] {exc}") + raise typer.Abort() from exc + + @type_app.command("show") def type_show( name: Annotated[ diff --git a/src/cleveragents/infrastructure/plugins/manager.py b/src/cleveragents/infrastructure/plugins/manager.py index 444a3b157..1c9d91c04 100644 --- a/src/cleveragents/infrastructure/plugins/manager.py +++ b/src/cleveragents/infrastructure/plugins/manager.py @@ -389,6 +389,84 @@ class PluginManager: registered.append(result) return registered + # ------------------------------------------------------------------ + # Resource type extension point + # ------------------------------------------------------------------ + + def register_resource_types( + self, + resource_registry: Any, + plugin_name: str, + ) -> list[str]: + """Register resource types declared by an activated plugin. + + Inspects the plugin instance for a ``resource_types`` attribute + (list of dicts with resource type configuration). Each type is + registered through ``ResourceRegistryService.register_plugin_resource_type()``. + + Args: + resource_registry: A ``ResourceRegistryService`` instance + (or compatible object with ``register_plugin_resource_type``). + plugin_name: Name of the activated plugin whose types to + register. + + Returns: + List of newly registered type names. + + Raises: + PluginNotFoundError: If the plugin is not registered. + PluginError: If the plugin is not activated. + """ + with self._lock: + descriptor = self.get_plugin(plugin_name) + if descriptor.state != PluginState.ACTIVATED: + msg = ( + f"Plugin '{plugin_name}' must be activated before " + "resource type registration" + ) + raise PluginError(msg) + + instance = self._instances.get(plugin_name) + if instance is None: + return [] + + type_configs: list[dict[str, Any]] = getattr(instance, "resource_types", []) + if not type_configs: + return [] + + registered: list[str] = [] + register_fn = getattr( + resource_registry, + "register_plugin_resource_type", + None, + ) + if not callable(register_fn): + self._logger.warning( + "plugin_manager.resource_registry_missing_register_fn", + plugin=plugin_name, + ) + return [] + + for cfg in type_configs: + try: + spec: Any = register_fn(cfg) + type_name: str = spec.name + registered.append(type_name) + self._logger.info( + "plugin_manager.resource_type_registered", + plugin=plugin_name, + type_name=type_name, + ) + except Exception as exc: + self._logger.warning( + "plugin_manager.resource_type_registration_failed", + plugin=plugin_name, + config=cfg, + error=str(exc), + ) + + return registered + # ------------------------------------------------------------------ # Cleanup # ------------------------------------------------------------------ diff --git a/vulture_whitelist.py b/vulture_whitelist.py index cef933f03..22f56222f 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -1182,6 +1182,14 @@ workspace_folder # noqa: B018, F821 execute_tool # noqa: B018, F821 wrap_service_method # noqa: B018, F821 +# Resource inheritance wiring — public API (#942) +type_tree # noqa: B018, F821 +discover_most_specific_type # noqa: B018, F821 +get_type_tree # noqa: B018, F821 +register_plugin_resource_type # noqa: B018, F821 +get_resource_inheritance_metadata # noqa: B018, F821 +register_resource_types # noqa: B018, F821 + # LSP functional runtime — public API (#826) StdioTransport # noqa: B018, F821 LspClient # noqa: B018, F821 -- 2.52.0