diff --git a/amplifier_app_cli/lib/bundle_loader/prepare.py b/amplifier_app_cli/lib/bundle_loader/prepare.py index 2ad5fcfd..ea0a23db 100644 --- a/amplifier_app_cli/lib/bundle_loader/prepare.py +++ b/amplifier_app_cli/lib/bundle_loader/prepare.py @@ -76,6 +76,7 @@ async def load_and_prepare_bundle( source_overrides: dict[str, str] | None = None, progress_callback: Callable[[str, str], None] | None = None, bundle_source_overrides: dict[str, str] | None = None, + required_behaviors: set[str] | None = None, ) -> PreparedBundle: """Load bundle by name or URI and prepare it for execution. @@ -108,6 +109,9 @@ async def load_and_prepare_bundle( Keys are matched as substrings of include URIs. If matched, the override URI is used instead. Example: {"amplifier-bundle-superpowers": "/local/path"} + required_behaviors: Optional subset of ``compose_behaviors`` whose load + or composition failures must propagate. Other behavior failures + remain warnings for optional policies such as notifications. Returns: PreparedBundle ready for create_session(). @@ -193,8 +197,10 @@ async def load_and_prepare_bundle( f"Composed behavior '{behavior_bundle.name}' onto '{bundle.name}'" ) except Exception as e: + if required_behaviors and behavior_uri in required_behaviors: + raise logger.warning(f"Failed to compose behavior '{behavior_uri}': {e}") - # Continue without this behavior - notifications are optional + # Continue without optional behaviors such as notifications. # 3b. Load agent metadata BEFORE prepare so the agent's declared modules # (tools/providers/hooks with `source:` URIs in their .md frontmatter) are diff --git a/amplifier_app_cli/runtime/config.py b/amplifier_app_cli/runtime/config.py index 17dfdfba..966c0769 100644 --- a/amplifier_app_cli/runtime/config.py +++ b/amplifier_app_cli/runtime/config.py @@ -94,8 +94,14 @@ def _on_progress(action: str, detail: str) -> None: _build_notification_behaviors(app_settings.get_notification_flags()) ) + # Routing is required when active: compose its canonical behavior before + # prepare() so hooks-routing and its source are available to all sessions. + routing_config = app_settings.get_routing_config() + routing_behaviors = _build_routing_behaviors(routing_config) + compose_behaviors.extend(routing_behaviors) + # Add app bundles (user-configured bundles that are always composed) - # App bundles are explicit user configuration, composed AFTER notification behaviors + # App bundles are explicit user configuration, composed after app policies. app_bundles = app_settings.get_app_bundles() if app_bundles: compose_behaviors = compose_behaviors + app_bundles @@ -134,6 +140,7 @@ def _on_progress(action: str, detail: str) -> None: bundle_name, discovery, compose_behaviors=compose_behaviors if compose_behaviors else None, + required_behaviors=set(routing_behaviors) if routing_behaviors else None, source_overrides=combined_sources if combined_sources else None, bundle_source_overrides=bundle_sources if bundle_sources else None, progress_callback=_on_progress if status else None, @@ -232,7 +239,6 @@ def _on_progress(action: str, detail: str) -> None: hook_overrides = app_settings.get_notification_hook_overrides() # Routing matrix config injection - routing_config = app_settings.get_routing_config() if routing_config: routing_hook_override: dict[str, Any] = { "module": "hooks-routing", @@ -315,8 +321,8 @@ def _on_progress(action: str, detail: str) -> None: prepared, bundle_config, sync_tools=bool(bundle_config.get("tools")) ) - # Note: Notification hooks are now composed via compose_behaviors parameter - # to load_and_prepare_bundle(), so they get properly installed during prepare(). + # Note: Notification and routing hooks are composed via compose_behaviors + # before prepare(), so they get properly installed during preparation. # The behavior bundles handle root-session-only logic internally via parent_id check. return bundle_config, prepared @@ -897,6 +903,24 @@ def _build_modes_behaviors() -> list[str]: ] +def _build_routing_behaviors(routing_config: dict[str, Any]) -> list[str]: + """Return the canonical routing behavior URI when routing is active. + + Composing this app-level policy before preparation makes ``hooks-routing`` + and its module source available in the prepared resolver for root and + delegated sessions. + """ + if not routing_config: + return [] + + return [ + ( + "git+https://github.com/microsoft/amplifier-bundle-routing-matrix@main" + "#subdirectory=behaviors/routing.yaml" + ) + ] + + def _build_notification_behaviors(flags: NotificationFlags) -> list[str]: """Build list of notification behavior URIs based on resolved flags. diff --git a/tests/lib/bundle_loader/test_prepare.py b/tests/lib/bundle_loader/test_prepare.py index 1875c3f2..a7d5f941 100644 --- a/tests/lib/bundle_loader/test_prepare.py +++ b/tests/lib/bundle_loader/test_prepare.py @@ -241,3 +241,100 @@ async def test_no_bundle_overrides_skips_resolver(self): # set_include_source_resolver was NOT called mock_registry.set_include_source_resolver.assert_not_called() + + +class TestLoadAndPrepareBundleRequiredBehaviors: + """Required behavior failures must not be hidden by the loader.""" + + @pytest.mark.asyncio + async def test_required_behavior_load_failure_propagates(self): + """A routing behavior load error aborts before bundle preparation.""" + from amplifier_app_cli.lib.bundle_loader.prepare import load_and_prepare_bundle + + routing_uri = ( + "git+https://github.com/microsoft/amplifier-bundle-routing-matrix@main" + "#subdirectory=behaviors/routing.yaml" + ) + mock_discovery = MagicMock() + mock_discovery.find.return_value = "file:///path/to/bundle.yaml" + mock_bundle = MagicMock() + mock_bundle.prepare = AsyncMock() + + with ( + patch( + "amplifier_app_cli.lib.bundle_loader.prepare.load_bundle", + new_callable=AsyncMock, + side_effect=[ + mock_bundle, + RuntimeError("hooks-routing module unavailable"), + ], + ), + pytest.raises(RuntimeError, match="hooks-routing module unavailable"), + ): + await load_and_prepare_bundle( + "my-bundle", + mock_discovery, + compose_behaviors=[routing_uri], + required_behaviors={routing_uri}, + ) + + mock_bundle.prepare.assert_not_awaited() + + @pytest.mark.asyncio + async def test_required_behavior_compose_failure_propagates(self): + """A routing behavior composition error aborts before preparation.""" + from amplifier_app_cli.lib.bundle_loader.prepare import load_and_prepare_bundle + + routing_uri = "file:///routing.yaml" + mock_discovery = MagicMock() + mock_discovery.find.return_value = "file:///path/to/bundle.yaml" + mock_bundle = MagicMock() + mock_bundle.compose.side_effect = RuntimeError("routing compose failed") + mock_bundle.prepare = AsyncMock() + behavior_bundle = MagicMock() + + with ( + patch( + "amplifier_app_cli.lib.bundle_loader.prepare.load_bundle", + new_callable=AsyncMock, + side_effect=[mock_bundle, behavior_bundle], + ), + pytest.raises(RuntimeError, match="routing compose failed"), + ): + await load_and_prepare_bundle( + "my-bundle", + mock_discovery, + compose_behaviors=[routing_uri], + required_behaviors={routing_uri}, + ) + + mock_bundle.prepare.assert_not_awaited() + + @pytest.mark.asyncio + async def test_optional_behavior_load_failure_is_still_ignored(self): + """Optional notification behavior failures remain warning-and-continue.""" + from amplifier_app_cli.lib.bundle_loader.prepare import load_and_prepare_bundle + + optional_uri = "file:///optional-notifications.yaml" + mock_discovery = MagicMock() + mock_discovery.find.return_value = "file:///path/to/bundle.yaml" + mock_bundle = MagicMock() + mock_prepared = MagicMock() + mock_bundle.prepare = AsyncMock(return_value=mock_prepared) + + with patch( + "amplifier_app_cli.lib.bundle_loader.prepare.load_bundle", + new_callable=AsyncMock, + side_effect=[ + mock_bundle, + RuntimeError("optional behavior unavailable"), + ], + ): + result = await load_and_prepare_bundle( + "my-bundle", + mock_discovery, + compose_behaviors=[optional_uri], + ) + + assert result is mock_prepared + mock_bundle.prepare.assert_awaited_once() diff --git a/tests/test_general_config_overrides.py b/tests/test_general_config_overrides.py index c6b79abe..4496cf2b 100644 --- a/tests/test_general_config_overrides.py +++ b/tests/test_general_config_overrides.py @@ -9,6 +9,7 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch from amplifier_app_cli.lib.merge_utils import deep_merge +from amplifier_app_cli.lib.settings import NotificationFlags from amplifier_app_cli.runtime.config import ( _apply_hook_overrides, _apply_provider_overrides, @@ -318,6 +319,11 @@ def _make_app_settings(config_overrides=None, **kwargs): "hook_overrides", [] ) settings.get_routing_config.return_value = kwargs.get("routing_config", None) + settings.get_notification_flags.return_value = NotificationFlags( + desktop_enabled=False, + push_enabled=False, + ) + settings.get_app_bundles.return_value = [] settings.get_source_overrides.return_value = {} settings.get_module_sources.return_value = {} settings.get_bundle_sources.return_value = {} @@ -331,6 +337,96 @@ class TestFullPipelineIntegration: at their SOURCE modules since they're imported inside the function body. """ + @pytest.mark.asyncio + async def test_active_routing_composed_before_prepare(self): + """Active routing is composed and required before preparation.""" + mock_prepared = MagicMock() + mock_prepared.mount_plan = {"hooks": []} + mock_prepared.bundle.load_agent_metadata = MagicMock() + settings = _make_app_settings( + routing_config={ + "matrix": "balanced", + "overrides": {"coding": "quality"}, + } + ) + prepare = AsyncMock(return_value=mock_prepared) + + with ( + patch( + "amplifier_app_cli.lib.bundle_loader.prepare.load_and_prepare_bundle", + prepare, + ), + patch("amplifier_app_cli.paths.get_bundle_search_paths", return_value=[]), + patch("amplifier_app_cli.lib.bundle_loader.AppBundleDiscovery"), + ): + result, _ = await resolve_bundle_config( + bundle_name="test", app_settings=settings + ) + + compose_behaviors = prepare.await_args.kwargs["compose_behaviors"] + required_behaviors = prepare.await_args.kwargs["required_behaviors"] + routing_uri = ( + "git+https://github.com/microsoft/amplifier-bundle-routing-matrix@main" + "#subdirectory=behaviors/routing.yaml" + ) + assert routing_uri in compose_behaviors + assert required_behaviors == {routing_uri} + assert required_behaviors <= set(compose_behaviors) + assert result["hooks"][0]["config"]["default_matrix"] == "balanced" + assert result["hooks"][0]["config"]["overrides"] == {"coding": "quality"} + settings.get_routing_config.assert_called_once_with() + + @pytest.mark.asyncio + async def test_inactive_routing_not_composed_before_prepare(self): + """Inactive routing is neither composed nor marked required.""" + mock_prepared = MagicMock() + mock_prepared.mount_plan = {"hooks": []} + mock_prepared.bundle.load_agent_metadata = MagicMock() + settings = _make_app_settings(routing_config={}) + prepare = AsyncMock(return_value=mock_prepared) + + with ( + patch( + "amplifier_app_cli.lib.bundle_loader.prepare.load_and_prepare_bundle", + prepare, + ), + patch("amplifier_app_cli.paths.get_bundle_search_paths", return_value=[]), + patch("amplifier_app_cli.lib.bundle_loader.AppBundleDiscovery"), + ): + result, _ = await resolve_bundle_config( + bundle_name="test", app_settings=settings + ) + + compose_behaviors = prepare.await_args.kwargs["compose_behaviors"] + assert all( + "amplifier-bundle-routing-matrix" not in uri for uri in compose_behaviors + ) + assert prepare.await_args.kwargs["required_behaviors"] is None + assert result["hooks"] == [] + settings.get_routing_config.assert_called_once_with() + + @pytest.mark.asyncio + async def test_routing_preparation_error_propagates(self): + """Preparation errors remain fatal when routing is active.""" + settings = _make_app_settings(routing_config={"matrix": "balanced"}) + prepare = AsyncMock(side_effect=RuntimeError("hooks-routing unavailable")) + + with ( + patch( + "amplifier_app_cli.lib.bundle_loader.prepare.load_and_prepare_bundle", + prepare, + ), + patch("amplifier_app_cli.paths.get_bundle_search_paths", return_value=[]), + patch("amplifier_app_cli.lib.bundle_loader.AppBundleDiscovery"), + pytest.raises(RuntimeError, match="hooks-routing unavailable"), + ): + await resolve_bundle_config(bundle_name="test", app_settings=settings) + + assert any( + "amplifier-bundle-routing-matrix" in uri + for uri in prepare.await_args.kwargs["compose_behaviors"] + ) + @pytest.mark.asyncio async def test_hook_override_flows_through_full_pipeline(self): """Config override for a hook reaches final bundle_config."""