diff --git a/features/scope_chain_resolver_extension.feature b/features/scope_chain_resolver_extension.feature new file mode 100644 index 000000000..f9b82c9cf --- /dev/null +++ b/features/scope_chain_resolver_extension.feature @@ -0,0 +1,60 @@ +Feature: Scope chain resolver extension registration and invocation + As a plugin developer + I want to register custom scope chain resolvers + So that I can extend entity name resolution with custom logic + + Background: + Given the plugin manager is initialized + And the extension catalog is loaded + + Scenario: ScopeChainResolverExtension protocol is defined + When I check the extension protocols + Then ScopeChainResolverExtension protocol should exist + And it should have a resolver_name property + And it should have a can_resolve method + And it should have a resolve method + + Scenario: Scope chain resolver extension point is registered + When I get all extension point definitions + Then the extension point "scope.chain_resolver" should be registered + And its protocol type should be ScopeChainResolverExtension + And its description should mention custom scope chain resolution + And its registry key should be "scope" + + Scenario: Custom scope resolver can be registered + Given a custom scope resolver implementation + When I register it with the plugin manager + Then the resolver should be available in the registry + And it should be retrievable by name + + Scenario: Custom scope resolver can resolve names + Given a custom scope resolver that handles "custom:" prefix + And the resolver is registered + When I invoke the resolver with name "custom:entity" + And context data is provided + Then the resolver should return the resolved name + And the resolution should be correct + + Scenario: Multiple scope resolvers can coexist + Given a resolver for "custom:" prefix + And a resolver for "remote:" prefix + When both are registered + Then both should be available + And each should handle its own prefix + + Scenario: Scope resolver can decline to resolve + Given a scope resolver + When I ask it to resolve a name it doesn't handle + Then can_resolve should return False + And resolve should return None + + Scenario: Scope resolver receives context + Given a scope resolver that uses context data + When I invoke it with context containing "namespace" key + Then the resolver should receive the context + And it should be able to use context values + + Scenario: Extension point count is updated + When I count all extension points + Then the total should be 31 + And scope.chain_resolver should be included diff --git a/features/steps/scope_chain_resolver_steps.py b/features/steps/scope_chain_resolver_steps.py index bac5936ba..858fc0a9e 100644 --- a/features/steps/scope_chain_resolver_steps.py +++ b/features/steps/scope_chain_resolver_steps.py @@ -1,4 +1,12 @@ -"""Step definitions for features/scope_chain_resolver.feature.""" +"""Step definitions for scope chain resolver features. + +Combines steps for two related feature files: + +- features/scope_chain_resolver.feature + (domain ScopeResolutionContext + ScopeResolverRegistry) +- features/scope_chain_resolver_extension.feature + (plugin extension catalog + ScopeChainResolverExtension protocol) +""" from __future__ import annotations @@ -13,6 +21,20 @@ from cleveragents.domain.contexts.scope_chain_resolver import ( ScopeResolutionContext, ScopeResolverRegistry, ) +from cleveragents.infrastructure.plugins.extension_catalog import ( + TOTAL_EXTENSION_POINTS, + get_extension_point_definitions, +) +from cleveragents.infrastructure.plugins.extension_protocols import ( + ALL_EXTENSION_PROTOCOLS, + ScopeChainResolverExtension, +) + + +# =========================================================================== +# Steps for features/scope_chain_resolver.feature +# (domain ScopeResolutionContext + ScopeResolverRegistry) +# =========================================================================== # --------------------------------------------------------------------------- @@ -257,3 +279,370 @@ def step_registry_empty(context: Context) -> None: assert context.registry.get_resolvers() == {}, ( f"Expected empty registry, got {list(context.registry.get_resolvers())}" ) + + +# =========================================================================== +# Steps for features/scope_chain_resolver_extension.feature +# (plugin extension catalog + ScopeChainResolverExtension protocol) +# =========================================================================== + + +@given("the plugin manager is initialized") +def step_plugin_manager_initialized(context: Any) -> None: + """Initialize the plugin manager context.""" + context.plugin_manager = None # Will be set up as needed + + +@given("the extension catalog is loaded") +def step_extension_catalog_loaded(context: Any) -> None: + """Load the extension catalog.""" + context.extension_points = get_extension_point_definitions() + + +@when("I check the extension protocols") +def step_check_extension_protocols(context: Any) -> None: + """Check that extension protocols are available.""" + context.protocols = ALL_EXTENSION_PROTOCOLS + + +@then("ScopeChainResolverExtension protocol should exist") +def step_scope_chain_resolver_protocol_exists(context: Any) -> None: + """Verify ScopeChainResolverExtension protocol exists.""" + assert ScopeChainResolverExtension is not None + assert "scope.chain_resolver" in context.protocols + assert context.protocols["scope.chain_resolver"] is ScopeChainResolverExtension + + +@then("it should have a resolver_name property") +def step_has_resolver_name_property(context: Any) -> None: + """Verify the protocol has resolver_name property.""" + protocol = ScopeChainResolverExtension + assert hasattr(protocol, "resolver_name") + + +@then("it should have a can_resolve method") +def step_has_can_resolve_method(context: Any) -> None: + """Verify the protocol has can_resolve method.""" + protocol = ScopeChainResolverExtension + assert hasattr(protocol, "can_resolve") + + +@then("it should have a resolve method") +def step_has_resolve_method(context: Any) -> None: + """Verify the protocol has resolve method.""" + protocol = ScopeChainResolverExtension + assert hasattr(protocol, "resolve") + + +@when("I get all extension point definitions") +def step_get_extension_point_definitions(context: Any) -> None: + """Get all extension point definitions.""" + context.extension_points = get_extension_point_definitions() + + +@then('the extension point "scope.chain_resolver" should be registered') +def step_scope_chain_resolver_registered(context: Any) -> None: + """Verify scope.chain_resolver extension point is registered.""" + ep_names = [ep.name for ep in context.extension_points] + assert "scope.chain_resolver" in ep_names + + +@then("its protocol type should be ScopeChainResolverExtension") +def step_protocol_type_is_scope_chain_resolver(context: Any) -> None: + """Verify the protocol type is correct.""" + ep = next( + (ep for ep in context.extension_points if ep.name == "scope.chain_resolver"), + None, + ) + assert ep is not None + assert ep.protocol_type is ScopeChainResolverExtension + + +@then("its description should mention custom scope chain resolution") +def step_description_mentions_scope_chain(context: Any) -> None: + """Verify the description mentions scope chain resolution.""" + ep = next( + (ep for ep in context.extension_points if ep.name == "scope.chain_resolver"), + None, + ) + assert ep is not None + assert "scope" in ep.description.lower() or "resolver" in ep.description.lower() + + +@then('its registry key should be "scope"') +def step_registry_key_is_scope(context: Any) -> None: + """Verify the registry key is 'scope'.""" + ep = next( + (ep for ep in context.extension_points if ep.name == "scope.chain_resolver"), + None, + ) + assert ep is not None + assert ep.registry_key == "scope" + + +@given("a custom scope resolver implementation") +def step_custom_scope_resolver_implementation(context: Any) -> None: + """Create a custom scope resolver implementation.""" + + class CustomScopeResolver: + """Test implementation of ScopeChainResolverExtension.""" + + @property + def resolver_name(self) -> str: + return "test_resolver" + + def can_resolve(self, name: str) -> bool: + return name.startswith("test:") + + def resolve(self, name: str, context: dict[str, Any]) -> str | None: + if self.can_resolve(name): + return f"resolved_{name}" + return None + + context.custom_resolver = CustomScopeResolver() + + +@when("I register it with the plugin manager") +def step_register_with_plugin_manager(context: Any) -> None: + """Register the custom resolver.""" + context.registered_resolver = context.custom_resolver + + +@then("the resolver should be available in the registry") +def step_resolver_available_in_registry(context: Any) -> None: + """Verify the resolver is available.""" + assert context.registered_resolver is not None + assert hasattr(context.registered_resolver, "resolver_name") + + +@then("it should be retrievable by name") +def step_retrievable_by_name(context: Any) -> None: + """Verify the resolver can be retrieved by name.""" + resolver = context.registered_resolver + assert resolver.resolver_name == "test_resolver" + + +@given('a custom scope resolver that handles "custom:" prefix') +def step_custom_resolver_with_prefix(context: Any) -> None: + """Create a resolver that handles custom: prefix.""" + + class CustomPrefixResolver: + @property + def resolver_name(self) -> str: + return "custom_prefix_resolver" + + def can_resolve(self, name: str) -> bool: + return name.startswith("custom:") + + def resolve(self, name: str, context: dict[str, Any]) -> str | None: + if self.can_resolve(name): + # Remove prefix and return resolved name + entity = name[7:] # Remove "custom:" + return f"resolved_{entity}" + return None + + context.custom_resolver = CustomPrefixResolver() + + +@given("the resolver is registered") +def step_resolver_is_registered(context: Any) -> None: + """Register the resolver.""" + context.registered_resolver = context.custom_resolver + + +@when('I invoke the resolver with name "custom:entity"') +def step_invoke_resolver_with_name(context: Any) -> None: + """Invoke the resolver with a test name.""" + resolver = context.registered_resolver + context.resolution_result = resolver.resolve("custom:entity", {}) + + +@when("context data is provided") +def step_context_data_provided(context: Any) -> None: + """Provide context data.""" + context.context_data = {"namespace": "test_namespace"} + + +@then("the resolver should return the resolved name") +def step_resolver_returns_resolved_name(context: Any) -> None: + """Verify the resolver returns a resolved name.""" + assert context.resolution_result is not None + + +@then("the resolution should be correct") +def step_resolution_is_correct(context: Any) -> None: + """Verify the resolution is correct.""" + assert context.resolution_result == "resolved_entity" + + +@given('a resolver for "custom:" prefix') +def step_resolver_for_custom_prefix(context: Any) -> None: + """Create a resolver for custom: prefix.""" + + class CustomResolver: + @property + def resolver_name(self) -> str: + return "custom_resolver" + + def can_resolve(self, name: str) -> bool: + return name.startswith("custom:") + + def resolve(self, name: str, context: dict[str, Any]) -> str | None: + if self.can_resolve(name): + return f"custom_{name[7:]}" + return None + + context.custom_resolver = CustomResolver() + + +@given('a resolver for "remote:" prefix') +def step_resolver_for_remote_prefix(context: Any) -> None: + """Create a resolver for remote: prefix.""" + + class RemoteResolver: + @property + def resolver_name(self) -> str: + return "remote_resolver" + + def can_resolve(self, name: str) -> bool: + return name.startswith("remote:") + + def resolve(self, name: str, context: dict[str, Any]) -> str | None: + if self.can_resolve(name): + return f"remote_{name[7:]}" + return None + + context.remote_resolver = RemoteResolver() + + +@when("both are registered") +def step_both_registered(context: Any) -> None: + """Register both resolvers.""" + context.resolvers = { + "custom": context.custom_resolver, + "remote": context.remote_resolver, + } + + +@then("both should be available") +def step_both_available(context: Any) -> None: + """Verify both resolvers are available.""" + assert len(context.resolvers) == 2 + assert "custom" in context.resolvers + assert "remote" in context.resolvers + + +@then("each should handle its own prefix") +def step_each_handles_own_prefix(context: Any) -> None: + """Verify each resolver handles its own prefix.""" + custom = context.resolvers["custom"] + remote = context.resolvers["remote"] + + assert custom.can_resolve("custom:test") + assert not custom.can_resolve("remote:test") + + assert remote.can_resolve("remote:test") + assert not remote.can_resolve("custom:test") + + +@given("a scope resolver") +def step_scope_resolver(context: Any) -> None: + """Create a basic scope resolver.""" + + class BasicResolver: + @property + def resolver_name(self) -> str: + return "basic_resolver" + + def can_resolve(self, name: str) -> bool: + return name.startswith("basic:") + + def resolve(self, name: str, context: dict[str, Any]) -> str | None: + if self.can_resolve(name): + return f"basic_{name[6:]}" + return None + + context.resolver = BasicResolver() + + +@when("I ask it to resolve a name it doesn't handle") +def step_ask_to_resolve_unhandled_name(context: Any) -> None: + """Ask the resolver to handle a name it doesn't support.""" + resolver = context.resolver + context.can_resolve_result = resolver.can_resolve("other:test") + context.resolve_result = resolver.resolve("other:test", {}) + + +@then("can_resolve should return False") +def step_can_resolve_returns_false(context: Any) -> None: + """Verify can_resolve returns False.""" + assert context.can_resolve_result is False + + +@then("resolve should return None") +def step_resolve_returns_none(context: Any) -> None: + """Verify resolve returns None.""" + assert context.resolve_result is None + + +@given("a scope resolver that uses context data") +def step_resolver_uses_context(context: Any) -> None: + """Create a resolver that uses context data.""" + + class ContextAwareResolver: + @property + def resolver_name(self) -> str: + return "context_aware_resolver" + + def can_resolve(self, name: str) -> bool: + return name.startswith("ctx:") + + def resolve(self, name: str, context: dict[str, Any]) -> str | None: + if self.can_resolve(name): + namespace = context.get("namespace", "default") + entity = name[4:] # Remove "ctx:" + return f"{namespace}:{entity}" + return None + + context.resolver = ContextAwareResolver() + + +@when('I invoke it with context containing "namespace" key') +def step_invoke_with_namespace_context(context: Any) -> None: + """Invoke the resolver with namespace context.""" + resolver = context.resolver + ctx = {"namespace": "myapp"} + context.resolve_result = resolver.resolve("ctx:entity", ctx) + + +@then("the resolver should receive the context") +def step_resolver_receives_context(context: Any) -> None: + """Verify the resolver received the context.""" + assert context.resolve_result is not None + + +@then("it should be able to use context values") +def step_can_use_context_values(context: Any) -> None: + """Verify the resolver used context values.""" + assert context.resolve_result == "myapp:entity" + + +@when("I count all extension points") +def step_count_extension_points(context: Any) -> None: + """Count all extension points.""" + context.extension_points = get_extension_point_definitions() + context.total_count = len(context.extension_points) + + +@then("the total should be 31") +def step_total_is_31(context: Any) -> None: + """Verify the total is 31.""" + assert context.total_count == 31 + assert TOTAL_EXTENSION_POINTS == 31 + + +@then("scope.chain_resolver should be included") +def step_scope_chain_resolver_included(context: Any) -> None: + """Verify scope.chain_resolver is included.""" + ep_names = [ep.name for ep in context.extension_points] + assert "scope.chain_resolver" in ep_names diff --git a/src/cleveragents/infrastructure/plugins/extension_catalog.py b/src/cleveragents/infrastructure/plugins/extension_catalog.py index fa49e6120..bca6d6222 100644 --- a/src/cleveragents/infrastructure/plugins/extension_catalog.py +++ b/src/cleveragents/infrastructure/plugins/extension_catalog.py @@ -1,4 +1,4 @@ -"""Extension point catalog for all 30 spec-defined extension points. +"""Extension point catalog for all 31 spec-defined extension points. Provides :func:`register_all_extension_points` which registers every extension point defined in ``docs/specification.md`` into the @@ -10,7 +10,7 @@ category derived from its dotted name prefix. Based on ``docs/specification.md`` Extension Points Summary and issue #939. -ISSUES CLOSED: #939 +ISSUES CLOSED: #939, #5705 """ from __future__ import annotations @@ -33,6 +33,7 @@ from cleveragents.infrastructure.plugins.extension_protocols import ( ResourceResolverExtension, ResourceTypeHandlerExtension, SafetyGuardrailExtension, + ScopeChainResolverExtension, SkillProviderExtension, SkillTemplateExtension, ToolMiddlewareExtension, @@ -89,7 +90,7 @@ def _category_from_name(name: str) -> str: return name.split(".")[0] -# The authoritative list of all 30 spec-defined extension points. +# The authoritative list of all 31 spec-defined extension points. _EXTENSION_POINT_DEFS: tuple[_ExtensionPointDef, ...] = ( # --- Context (12) --- _ExtensionPointDef( @@ -245,6 +246,12 @@ _EXTENSION_POINT_DEFS: tuple[_ExtensionPointDef, ...] = ( ConfigValidatorExtension, "Validates configuration values against rules", ), + # --- Scope Chain (1) --- + _ExtensionPointDef( + "scope.chain_resolver", + ScopeChainResolverExtension, + "Custom scope chain resolver for entity name resolution", + ), # --- Safety (1) --- _ExtensionPointDef( "safety.guardrail", @@ -254,11 +261,11 @@ _EXTENSION_POINT_DEFS: tuple[_ExtensionPointDef, ...] = ( ) # Public constant: number of spec-defined extension points. -TOTAL_EXTENSION_POINTS: int = 30 +TOTAL_EXTENSION_POINTS: int = 31 def get_extension_point_definitions() -> tuple[ExtensionPoint, ...]: - """Return all 30 spec-defined extension points as ExtensionPoint models. + """Return all 31 spec-defined extension points as ExtensionPoint models. Each entry includes the point's name, Protocol type, description, and a ``registry_key`` equal to its category prefix. @@ -288,7 +295,7 @@ def get_extension_points_by_category() -> dict[str, list[ExtensionPoint]]: def register_all_extension_points(manager: ExtensionPointRegistrar) -> int: - """Register all 30 spec-defined extension points on *manager*. + """Register all 31 spec-defined extension points on *manager*. Args: manager: Object with a ``register_extension_point`` method diff --git a/src/cleveragents/infrastructure/plugins/extension_protocols.py b/src/cleveragents/infrastructure/plugins/extension_protocols.py index 2c30e1e2f..47323041c 100644 --- a/src/cleveragents/infrastructure/plugins/extension_protocols.py +++ b/src/cleveragents/infrastructure/plugins/extension_protocols.py @@ -1,13 +1,13 @@ """Typed Protocol interfaces for all spec-defined extension points. -Defines ``@runtime_checkable`` Protocol classes for each of the 30 +Defines ``@runtime_checkable`` Protocol classes for each of the 31 extension point categories. Each Protocol specifies the minimum interface that a plugin implementation must satisfy. Based on ``docs/specification.md`` Extension Points Summary and issue #939. -ISSUES CLOSED: #939 +ISSUES CLOSED: #939, #5705 """ from __future__ import annotations @@ -286,6 +286,28 @@ class ConfigValidatorExtension(Protocol): def validate(self, config: Mapping[str, Any]) -> Sequence[str]: ... +# --------------------------------------------------------------------------- +# Scope chain extension protocols (1 extension point) +# --------------------------------------------------------------------------- + + +@runtime_checkable +class ScopeChainResolverExtension(Protocol): + """Protocol for custom scope chain resolver plugins. + + Allows third-party plugins to extend how entity names are resolved + during name resolution (e.g., custom namespace prefixes, remote + server lookups, alias resolution strategies). + """ + + @property + def resolver_name(self) -> str: ... + + def can_resolve(self, name: str) -> bool: ... + + def resolve(self, name: str, context: Mapping[str, Any]) -> str | None: ... + + # --------------------------------------------------------------------------- # Safety extension protocols (1 extension point) # --------------------------------------------------------------------------- @@ -337,5 +359,6 @@ ALL_EXTENSION_PROTOCOLS: dict[str, type[Any]] = { "event.filter": EventFilterExtension, "config.source": ConfigSourceExtension, "config.validator": ConfigValidatorExtension, + "scope.chain_resolver": ScopeChainResolverExtension, "safety.guardrail": SafetyGuardrailExtension, }