Skip to content
Open
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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,39 @@ amplifier routing list # List available ma
amplifier routing use <name> [--local|--project|--global] # Select active matrix
amplifier routing show [<name>] # Show resolved roles for a matrix
amplifier routing manage # Interactive routing dashboard
```

### Routing matrix precedence

A bundle may declare a default routing matrix (a top-level `routing:` section
in its frontmatter). This is a DEFAULT ONLY -- it is the weakest source in
the precedence chain, so any settings-scope selection always wins over it.
Precedence, weakest to strongest:

```
built-in default
< bundle-declared routing.matrix
< ~/.amplifier/settings.yaml (global)
< .amplifier/settings.yaml (project)
< .amplifier/settings.local.yaml (project-local, gitignored)
```

If a bundle declares no routing matrix, behavior is exactly as before this
feature existed -- the bundle default only ever applies when nothing else in
settings has set `routing.matrix`.

`amplifier routing show` (with no explicit matrix name) prints a `Source:`
line above the resolved role table, naming the settings scope file that set
the active matrix (or `built-in default` when none did). `amplifier bundle
show` prints a `Routing matrix: <name> (bundle default)` line when the
active bundle declares one.

When a bundle's declared matrix isn't installed (not found under
`~/.amplifier/routing` or the cached routing-matrix bundle), the CLI drops
it, warns loudly, and falls back to the next-weakest configured matrix (or
no routing) -- a bad bundle default can never brick a session.

```bash
# Module management
amplifier module add <name> [--local|--project|--global]
amplifier module remove <name> [--scope]
Expand Down
11 changes: 11 additions & 0 deletions amplifier_app_cli/commands/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,17 @@ def _fmt_step(s: Any) -> str:
active = bundle_obj.name == active_bundle
console.print(f"{indent}active: {'yes' if active else 'no'}")

# Bundle-declared routing default (a top-level ``routing:`` section in
# bundle frontmatter). Read defensively via getattr() -- forward-compat
# with installs of amplifier-foundation that don't declare a ``routing``
# field on Bundle yet.
bundle_routing = getattr(bundle_obj, "routing", None) or {}
if isinstance(bundle_routing, dict) and bundle_routing.get("matrix"):
console.print(
f"{indent}Routing matrix: {escape_markup(str(bundle_routing['matrix']))} "
"(bundle default)"
)

if view == "detailed":
# Full mount plan details
providers = mount_plan.get("providers", [])
Expand Down
122 changes: 44 additions & 78 deletions amplifier_app_cli/commands/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,22 @@
from rich.prompt import Confirm, Prompt
from rich.table import Table

from ..lib.bundle_loader.discovery import WELL_KNOWN_BUNDLES
from ..lib.settings import AppSettings, Scope, get_custom_routing_dir
from ..lib.routing_matrices import discover_matrix_files as _discover_matrix_files_impl
from ..lib.settings import AppSettings, Scope
from ..provider_loader import get_provider_info, get_provider_models
from ..provider_manager import resolve_provider_entry
from ..ui.item_renderer import ItemRenderer
from ..ui.view_policy import resolve_view, view_flags
from ..ui.scope import (
is_scope_change_available,
print_scope_indicator,
prompt_scope_change,
validate_scope_cli,
)
from ..ui.view_policy import resolve_view, view_flags

console = Console()
logger = logging.getLogger(__name__)

# Single source of truth for the routing-matrix bundle URL lives in
# WELL_KNOWN_BUNDLES (discovery.py). The CLI lazy-fetches on first use so
# that `amplifier routing list` works on a clean install without requiring
# a prior `amplifier update`.
_ROUTING_BUNDLE_URI = str(WELL_KNOWN_BUNDLES["routing-matrix"]["remote"])

INFRASTRUCTURE_CONFIG_FIELDS = frozenset(
{
"base_url",
Expand Down Expand Up @@ -73,72 +67,17 @@ def _get_settings() -> AppSettings:
return AppSettings()


def _ensure_routing_bundle_cached() -> None:
"""Fetch the routing-matrix bundle into the cache if not yet present.

Called lazily from _discover_matrix_files() so `amplifier routing list`
works on a clean install without requiring the user to run
`amplifier update` first. FoundationGitSource.resolve() is a sync
wrapper that is safe to call from a synchronous CLI command (it spawns
a ThreadPoolExecutor internally if an event loop is already running).

Failures are reported both to the debug log and visibly to the user so
that a silent blocking clone followed by a silent empty list can never
happen.
"""
from ..lib.bundle_loader.resolvers import FoundationGitSource

try:
FoundationGitSource(_ROUTING_BUNDLE_URI).resolve()
except Exception as e:
logger.warning("Could not fetch routing-matrix bundle: %s", e)
console.print(f"[yellow]Could not fetch routing-matrix bundle: {e}[/yellow]")


def _discover_matrix_files() -> list[Path]:
"""Discover available routing matrix YAML files.

Looks in:
1. ~/.amplifier/cache/amplifier-bundle-routing-matrix-*/routing/*.yaml (bundle)
2. ~/.amplifier/routing/*.yaml (custom user matrices)

On a clean install where the bundle is not yet cached, this lazily
fetches the routing-matrix bundle on first use. That makes `amplifier
routing list` work out of the box instead of silently returning an
empty list and telling the user to run a different command.
"""Discover available routing matrix YAML files, fetching if needed.

Thin wrapper around ``lib.routing_matrices.discover_matrix_files()``
with ``fetch=True`` -- kept as a module-level name here for backward
compatibility with existing call sites in this module. The actual
filesystem-scanning + lazy-fetch implementation lives in
``lib/routing_matrices.py`` so ``runtime/config.py`` can share it (via
``known_matrix_names()``) without ever triggering the fetch.
"""
home = Path.home()
files: list[Path] = []

# Bundle cache matrices (lazy-fetch on first use)
cache_base = home / ".amplifier" / "cache"
bundle_dirs = (
list(cache_base.glob("amplifier-bundle-routing-matrix-*"))
if cache_base.exists()
else []
)
if not bundle_dirs:
# First run on a clean install — fetch the bundle with visible feedback.
# This can take 5-30s on a slow network so we must NOT block silently.
console.print("[dim]Fetching routing-matrix bundle...[/dim]")
_ensure_routing_bundle_cached()
bundle_dirs = (
list(cache_base.glob("amplifier-bundle-routing-matrix-*"))
if cache_base.exists()
else []
)

for bundle_dir in bundle_dirs:
routing_dir = bundle_dir / "routing"
if routing_dir.is_dir():
files.extend(routing_dir.glob("*.yaml"))

# Custom user matrices (single source of truth: get_custom_routing_dir())
custom_dir = get_custom_routing_dir()
if custom_dir.is_dir():
files.extend(custom_dir.glob("*.yaml"))

return sorted(files)
return _discover_matrix_files_impl(fetch=True)


def _load_matrix(path: Path) -> dict[str, Any] | None:
Expand Down Expand Up @@ -355,9 +294,14 @@ def routing_show(matrix_name: str | None, compact: bool, detailed: bool, fmt: st
console.print("[yellow]No routing matrices found.[/yellow]")
return

# Determine which matrix to show
# Determine which matrix to show. When no explicit name is given, this is
# the active matrix -- track where that selection came from (settings
# scope file, or the built-in default) so the non-detailed view below can
# show a "Source:" line.
source_path: str | None = None
auto_detected = matrix_name is None
if matrix_name is None:
routing_config = settings.get_routing_config()
routing_config, source_path = settings.get_routing_config_with_source()
matrix_name = routing_config.get("matrix", "balanced")

if matrix_name not in matrices:
Expand All @@ -381,14 +325,36 @@ def routing_show(matrix_name: str | None, compact: bool, detailed: bool, fmt: st
if view == "detailed":
_show_matrix_details(matrix_data, settings)
else:
_show_matrix_resolution(matrix_data, settings)
_show_matrix_resolution(
matrix_data, settings, show_source=auto_detected, source_path=source_path
)


def _show_matrix_resolution(matrix_data: dict[str, Any], settings: AppSettings) -> None:
"""Display a role-by-role resolution table for a matrix."""
def _show_matrix_resolution(
matrix_data: dict[str, Any],
settings: AppSettings,
*,
show_source: bool = False,
source_path: str | None = None,
) -> None:
"""Display a role-by-role resolution table for a matrix.

Args:
show_source: When True, print a "Source:" line above the table
identifying where the active-matrix selection came from (a
settings scope file, or "built-in default" when none set it).
Only ``amplifier routing show`` (with no explicit matrix name)
passes this -- other call sites (``routing use`` preview,
``routing manage``) are unaffected.
source_path: The settings scope file path from
``AppSettings.get_routing_config_with_source()``, or None.
"""
matrix_name = matrix_data.get("name", "unknown")
provider_types = _get_configured_provider_types(settings)

if show_source:
console.print(f"[dim]Source: {source_path or 'built-in default'}[/dim]")

roles = matrix_data.get("roles", {})
if not roles:
console.print(f"[yellow]Matrix '{matrix_name}' has no roles defined.[/yellow]")
Expand Down
43 changes: 42 additions & 1 deletion amplifier_app_cli/lib/bundle_loader/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ 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,
on_bundle_loaded: Callable[[Bundle], list[str]] | None = None,
) -> PreparedBundle:
"""Load bundle by name or URI and prepare it for execution.

Expand Down Expand Up @@ -108,6 +110,22 @@ 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.
on_bundle_loaded: Optional callback invoked with the freshly loaded
``Bundle`` immediately after step 2 (load) -- BEFORE any
``compose_behaviors`` (app-policy behaviors like modes,
notifications, routing) are composed onto it. This is the one
point where a caller can inspect bundle-declared defaults (e.g.
a bundle's own ``routing:`` section) and decide whether to
compose an additional behavior in response, without triggering
a second bundle load. The callback returns a list of additional
behavior URIs to compose (may be empty); these are appended to
``compose_behaviors`` (de-duplicated) and to ``required_behaviors``
for this call. Ordering matters: running this before app-policy
composition means an app-injected behavior can never masquerade
as a bundle default.

Returns:
PreparedBundle ready for create_session().
Expand Down Expand Up @@ -160,6 +178,13 @@ async def load_and_prepare_bundle(

logger.info(f"Loading bundle '{bundle_name}' from {uri}")

# Normalize to mutable local collections so on_bundle_loaded() below can
# append additional behaviors/requirements without the caller needing to
# pre-size compose_behaviors/required_behaviors for a callback result it
# can't know in advance.
compose_behaviors = list(compose_behaviors) if compose_behaviors else []
required_behaviors = set(required_behaviors) if required_behaviors else set()

if progress_callback:
progress_callback("loading", bundle_name)

Expand All @@ -177,6 +202,20 @@ async def load_and_prepare_bundle(
bundle = await load_bundle(uri, registry=discovery.registry)
logger.debug(f"Loaded bundle: {bundle.name} v{bundle.version}")

# 2b. Let the caller react to the freshly loaded bundle's OWN declared
# defaults (e.g. a bundle-declared routing matrix) before any app-policy
# behavior is composed onto it. Firing this here -- after includes are
# composed by load_bundle() but BEFORE compose_behaviors below -- means
# an app-injected behavior can never masquerade as a bundle default: the
# callback only ever sees what the bundle itself (plus its includes)
# declared.
if on_bundle_loaded:
extra_behaviors = on_bundle_loaded(bundle) or []
for extra_uri in extra_behaviors:
if extra_uri not in compose_behaviors:
compose_behaviors.append(extra_uri)
required_behaviors.add(extra_uri)

# 3. Compose additional behavior bundles (app-level policies like notifications)
if compose_behaviors:
for behavior_uri in compose_behaviors:
Expand All @@ -193,8 +232,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
Expand Down
Loading
Loading