Skip to content
Merged
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
52 changes: 36 additions & 16 deletions amplifier_app_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ def check_first_run() -> bool:
API key presence, since not all providers require API keys (e.g., Ollama, vLLM,
Azure OpenAI with CLI auth).

IMPORTANT: If no provider is configured, init MUST be run. We do NOT silently
pick a default provider based on environment variables - the user must explicitly
configure their provider via `amplifier init`. This ensures:
1. User explicitly chooses their provider
2. No surprise defaults that may not match bundle requirements
3. Clear error path when nothing is configured

If a provider is configured but its module is missing (post-update scenario where
`amplifier update` wiped the venv), this function will automatically reinstall
all known provider modules without user interaction. We install ALL providers
Expand All @@ -79,41 +86,54 @@ def check_first_run() -> bool:
provider_mgr = ProviderManager(config)
current_provider = provider_mgr.get_current_provider()

# No provider configured = true first run, need interactive init
if current_provider is None:
# Check if any provider's credentials are in environment
from ..provider_env_detect import detect_provider_from_env
logger.debug(
f"check_first_run: current_provider={current_provider.module_id if current_provider else None}"
)

detected_provider = detect_provider_from_env()
if detected_provider is not None:
# Provider credentials found in env - auto-configure it
logger.info(f"Auto-configuring provider from env vars: {detected_provider}")
# Auto-configure with minimal config (credentials from env)
provider_mgr.use_provider(detected_provider, scope="global", config={})
return False
# No provider configured = MUST run init
# Do NOT silently pick defaults from env vars - user must explicitly configure
if current_provider is None:
logger.info(
"No provider configured in settings - init required. "
"User must explicitly configure a provider via 'amplifier init'."
)
return True

# Provider is configured - check if its module is actually installed
if not _is_provider_module_installed(current_provider.module_id):
module_installed = _is_provider_module_installed(current_provider.module_id)
logger.debug(
f"check_first_run: provider={current_provider.module_id}, "
f"module_installed={module_installed}"
)

if not module_installed:
# Post-update scenario: settings exist but provider modules were wiped
# Auto-fix by reinstalling ALL known providers (bundles may need multiple)
logger.info(
f"Provider {current_provider.module_id} is configured but not installed. "
"Auto-installing providers (this can happen after `amplifier update`)..."
f"Provider {current_provider.module_id} is configured but module not installed. "
"Auto-installing providers (this can happen after 'amplifier update')..."
)
console.print("[dim]Installing provider modules...[/dim]")

installed = install_known_providers(config, console, verbose=True)
if installed:
# Successfully reinstalled - no need for full init
logger.debug("check_first_run: auto-install succeeded, no init needed")
console.print()
return False
else:
# Auto-fix failed - fall back to full init
logger.warning("Failed to auto-install providers, will prompt for init")
logger.warning(
"Failed to auto-install providers after detecting missing modules. "
"Will prompt user for init."
)
return True

# Provider configured and module installed - no init needed
logger.debug(
f"check_first_run: provider {current_provider.module_id} configured and installed, "
"no init needed"
)
return False


Expand Down Expand Up @@ -306,7 +326,7 @@ def init_cmd(non_interactive: bool = False):

# Save provider configuration to user's global settings (~/.amplifier/settings.yaml)
# This is first-time setup, so it should be available across all projects

provider_mgr.use_provider(
module_id, scope="global", config=provider_config, source=None
)
Expand Down
31 changes: 19 additions & 12 deletions amplifier_app_cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ def run(
if not bundle:
bundle = "foundation"

# Check if first run init is needed
# This runs unconditionally - --provider just selects from configured providers,
# it doesn't bypass the need for configuration
if check_first_run() and prompt_first_run_init(console):
pass # First run init completed

Expand All @@ -163,22 +166,26 @@ def run(
except BundleValidationError as exc:
# Bundle validation failed (e.g., malformed YAML, missing required fields)
console.print()
console.print(Panel(
str(exc),
title="[bold white on red] Bundle Validation Error [/bold white on red]",
border_style="red",
padding=(1, 2),
))
console.print(
Panel(
str(exc),
title="[bold white on red] Bundle Validation Error [/bold white on red]",
border_style="red",
padding=(1, 2),
)
)
sys.exit(1)
except BundleError as exc:
# General bundle error (loading, resolution, etc.)
console.print()
console.print(Panel(
str(exc),
title="[bold white on red] Bundle Error [/bold white on red]",
border_style="red",
padding=(1, 2),
))
console.print(
Panel(
str(exc),
title="[bold white on red] Bundle Error [/bold white on red]",
border_style="red",
padding=(1, 2),
)
)
sys.exit(1)

search_paths = get_module_search_paths()
Expand Down
128 changes: 112 additions & 16 deletions amplifier_app_cli/session_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,14 +404,21 @@ def _should_attempt_self_healing(
) -> bool:
"""Check if self-healing should be attempted for a session.

Self-healing is needed when modules were configured but failed to load.
Self-healing is needed when modules were configured but COMPLETELY failed to load.
The kernel intentionally swallows module load errors for resilience,
so we detect "configured but not loaded" by comparing mount plan to
actually mounted modules.

This typically happens when install-state.json says modules are installed,
but dependencies are missing (e.g., after uv tool reinstall).

IMPORTANT: We only trigger self-healing on COMPLETE failure (no modules loaded),
not partial failure (some modules loaded). Partial failures are often benign
(e.g., Azure OpenAI failing if user doesn't need it) and the session can
continue with the providers that did load. Self-healing on partial failures
causes more problems than it solves because it can't actually fix the issue
(would need to re-prepare the bundle).

Module types checked:
- providers: coordinator.get("providers") returns dict
- tools: coordinator.get("tools") returns dict
Expand All @@ -423,7 +430,7 @@ def _should_attempt_self_healing(
prepared_bundle: The bundle that was used to create the session.

Returns:
True if self-healing should be attempted.
True if self-healing should be attempted (only on complete failure).
"""
mount_plan = prepared_bundle.mount_plan
coordinator = session.coordinator
Expand All @@ -433,30 +440,70 @@ def _should_attempt_self_healing(
configured_providers = mount_plan.get("providers", [])
mounted_providers = coordinator.get("providers") or {}

# Extract provider IDs for logging
configured_provider_ids = [
p.get("module", p) if isinstance(p, dict) else str(p)
for p in configured_providers
]
mounted_provider_ids = list(mounted_providers.keys())

logger.debug(
f"self_healing_check: configured_providers={configured_provider_ids}, "
f"mounted_providers={mounted_provider_ids}"
)

# Only heal on COMPLETE failure - no providers loaded at all
if configured_providers and not mounted_providers:
logger.debug("No providers loaded despite configuration - likely stale install")
logger.info(
f"COMPLETE provider failure detected: {len(configured_providers)} configured, "
f"0 loaded. Configured: {configured_provider_ids}. Triggering self-healing."
)
return True

# Partial provider failure (some loaded, some failed)
# Partial provider failure - log warning but continue with what loaded
# Don't trigger self-healing for partial failures (often benign)
if len(mounted_providers) < len(configured_providers):
logger.debug(
f"Only {len(mounted_providers)}/{len(configured_providers)} providers loaded"
failed_providers = set(configured_provider_ids) - set(mounted_provider_ids)
logger.warning(
f"Partial provider failure: {len(mounted_providers)}/{len(configured_providers)} loaded. "
f"Failed: {failed_providers}. Loaded: {mounted_provider_ids}. "
"Session continuing with available providers (self-healing NOT triggered for partial failure)."
)
return True
# Don't return True - let session continue with partial providers

# --- Tools ---
# coordinator.get("tools") returns dict (public API)
configured_tools = mount_plan.get("tools", [])
mounted_tools = coordinator.get("tools") or {}

# Extract tool IDs for logging
configured_tool_ids = [
t.get("module", t) if isinstance(t, dict) else str(t) for t in configured_tools
]
mounted_tool_ids = list(mounted_tools.keys())

logger.debug(
f"self_healing_check: configured_tools={len(configured_tool_ids)}, "
f"mounted_tools={len(mounted_tool_ids)}"
)

# Only heal on COMPLETE failure - no tools loaded at all
if configured_tools and not mounted_tools:
logger.debug("No tools loaded despite configuration - likely stale install")
logger.info(
f"COMPLETE tool failure detected: {len(configured_tools)} configured, "
f"0 loaded. Triggering self-healing."
)
return True

# Partial tool failure (some loaded, some failed)
# Partial tool failure - log warning but continue with what loaded
if len(mounted_tools) < len(configured_tools):
logger.debug(f"Only {len(mounted_tools)}/{len(configured_tools)} tools loaded")
return True
failed_tools = set(configured_tool_ids) - set(mounted_tool_ids)
logger.warning(
f"Partial tool failure: {len(mounted_tools)}/{len(configured_tools)} loaded. "
f"Failed: {failed_tools}. "
"Session continuing with available tools (self-healing NOT triggered for partial failure)."
)
# Don't return True - let session continue with partial tools

# --- Hooks ---
# HookRegistry always exists at coordinator.get("hooks"), individual hook
Expand All @@ -466,6 +513,9 @@ def _should_attempt_self_healing(
# These are required and raise RuntimeError on failure during session.initialize().
# If we reach this point, they loaded successfully. No check needed.

logger.debug(
"self_healing_check: no complete failures detected, self-healing not needed"
)
return False


Expand All @@ -481,28 +531,74 @@ def _invalidate_all_install_state(prepared_bundle: "PreparedBundle") -> None:
"""
try:
resolver = prepared_bundle.resolver
# Access the activator (BundleModuleResolver stores it as _activator)
resolver_type = type(resolver).__name__
logger.debug(f"invalidate_install_state: resolver type is {resolver_type}")

# Access the activator - handle both direct BundleModuleResolver
# and AppModuleResolver (which wraps BundleModuleResolver in _bundle)
activator = getattr(resolver, "_activator", None)
if activator:
logger.debug(
f"invalidate_install_state: found activator directly on {resolver_type}"
)
else:
# Try unwrapping AppModuleResolver to get underlying BundleModuleResolver
bundle_resolver = getattr(resolver, "_bundle", None)
if bundle_resolver:
bundle_resolver_type = type(bundle_resolver).__name__
logger.debug(
f"invalidate_install_state: unwrapping {resolver_type} -> {bundle_resolver_type}"
)
activator = getattr(bundle_resolver, "_activator", None)
if activator:
logger.debug(
f"invalidate_install_state: found activator on wrapped {bundle_resolver_type}"
)
else:
logger.debug(
f"invalidate_install_state: no _bundle attribute on {resolver_type}"
)

if not activator:
logger.warning("No activator found - cannot invalidate install state")
logger.warning(
f"No activator found on resolver ({resolver_type}) - cannot invalidate install state. "
"This may happen if the bundle was not prepared with an activator."
)
return

activator_type = type(activator).__name__
logger.debug(f"invalidate_install_state: activator type is {activator_type}")

# Access install state manager
install_state = getattr(activator, "_install_state", None)
if not install_state:
logger.warning("No install state manager found - cannot invalidate")
logger.warning(
f"No install state manager found on activator ({activator_type}) - cannot invalidate. "
"This may happen if ModuleActivator was created without install state tracking."
)
return

install_state_type = type(install_state).__name__
logger.debug(
f"invalidate_install_state: install_state type is {install_state_type}"
)

# Invalidate all modules
install_state.invalidate(None)
install_state.save()
logger.info("Invalidated all install state for self-healing")
logger.info(
"Successfully invalidated all install state for self-healing. "
"Modules will be reinstalled on next activation."
)

# Clear the activator's activated set so it will re-activate all modules
activated = getattr(activator, "_activated", None)
if activated:
num_activated = len(activated)
activated.clear()
logger.debug("Cleared activator's activated set")
logger.debug(
f"Cleared activator's activated set ({num_activated} modules were marked as activated)"
)

except Exception as e:
logger.warning(f"Failed to invalidate install state: {e}")
15 changes: 14 additions & 1 deletion amplifier_app_cli/session_spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,11 +635,24 @@ async def resume_sub_session(sub_session_id: str, instruction: str) -> dict:
# Restore BundleModuleResolver with saved module paths
from amplifier_foundation.bundle import BundleModuleResolver

from amplifier_app_cli.lib.bundle_loader import AppModuleResolver

module_paths = {k: Path(v) for k, v in bundle_context["module_paths"].items()}
resolver = BundleModuleResolver(module_paths=module_paths)
bundle_resolver = BundleModuleResolver(module_paths=module_paths)
logger.debug(
f"Restored BundleModuleResolver with {len(module_paths)} module paths"
)

# Wrap with AppModuleResolver to provide fallback to settings resolver
# This is critical for modules (like providers) that may not be in the saved
# module_paths but are available via user settings/installed providers.
# Mirrors the wrapping done in session_runner.py and tool.py
fallback_resolver = create_foundation_resolver()
resolver = AppModuleResolver(
bundle_resolver=bundle_resolver,
settings_resolver=fallback_resolver,
)
logger.debug("Wrapped with AppModuleResolver for settings fallback")
else:
# Fallback to FoundationSettingsResolver
resolver = create_foundation_resolver()
Expand Down