From 9f68fe9390ff19c572f942944aa33fd4cb690e55 Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Wed, 29 Jul 2026 10:10:22 -0700 Subject: [PATCH] fix: make agent resolution canonical across runtimes Use one concrete agent registry and selection pipeline for headless, ACP, and embedded subagent execution. Preserve provider precedence while making built-in server dependencies explicit so builder call order cannot silently produce an empty delegation catalog. --- crates/aether-cli/src/acp/mod.rs | 3 +- crates/aether-cli/src/acp/session_actor.rs | 6 +- crates/aether-cli/src/acp/session_agents.rs | 31 +++ crates/aether-cli/src/acp/session_factory.rs | 170 +++++++-------- crates/aether-cli/src/acp/state.rs | 3 +- crates/aether-cli/src/acp/testing.rs | 16 +- crates/aether-cli/src/headless/mod.rs | 206 +++--------------- crates/aether-cli/src/headless/run.rs | 4 +- crates/aether-cli/src/resolve.rs | 148 ++++++------- crates/aether-cli/src/runtime.rs | 24 +- crates/aether-cli/src/show_prompt/run.rs | 4 +- crates/aether-core/src/agent_spec.rs | 7 +- crates/aether-core/src/core/agent_deps.rs | 14 +- crates/aether-core/src/core/agent_registry.rs | 72 ++++++ crates/aether-core/src/core/mod.rs | 2 + crates/aether-project/src/agent_catalog.rs | 186 ++++++++++++++-- crates/mcp-servers/README.md | 2 +- crates/mcp-servers/src/bin/stdio.rs | 2 +- .../mcp-servers/src/docs/mcp_builder_ext.md | 4 +- crates/mcp-servers/src/docs/subagents_mcp.md | 15 +- crates/mcp-servers/src/setup.rs | 184 ++++++++-------- crates/mcp-servers/src/subagents/server.rs | 65 +++--- .../src/subagents/tools/spawn_subagent/mod.rs | 21 +- .../tests/integration/plugins_mcp_agents.rs | 164 +++++++++++++- 24 files changed, 799 insertions(+), 554 deletions(-) create mode 100644 crates/aether-cli/src/acp/session_agents.rs create mode 100644 crates/aether-core/src/core/agent_registry.rs diff --git a/crates/aether-cli/src/acp/mod.rs b/crates/aether-cli/src/acp/mod.rs index 40d330334..22addf0cd 100644 --- a/crates/aether-cli/src/acp/mod.rs +++ b/crates/aether-cli/src/acp/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod model_config; pub(crate) mod prompt_history_index; pub(crate) mod protocol; pub(crate) mod session_actor; +pub(crate) mod session_agents; pub(crate) mod session_config_state; pub(crate) mod session_factory; pub(crate) mod session_store; @@ -42,11 +43,11 @@ use tracing_appender::rolling::daily; use tracing_subscriber::EnvFilter; use crate::credentials::oauth_credential_store_from_config; +use crate::resolve::InitialSessionSelection; use crate::telemetry::build_telemetry_runtime; use crate::workspace::WorkspaceManager; use aether_auth::OAuthError; use aether_project::SettingsError; -use session_factory::InitialSessionSelection; use session_store::SessionStore; #[derive(clap::Args, Debug)] diff --git a/crates/aether-cli/src/acp/session_actor.rs b/crates/aether-cli/src/acp/session_actor.rs index 2c81c6023..2fbeb3a27 100644 --- a/crates/aether-cli/src/acp/session_actor.rs +++ b/crates/aether-cli/src/acp/session_actor.rs @@ -1,7 +1,6 @@ use acp_utils::notifications::{ElicitationParams, McpNotification}; use acp_utils::server::AcpServerError; use aether_auth::OAuthCredentialStorage; -use aether_core::agent_spec::AgentSpec; use aether_core::context::ext::conversation_messages_from_events; use aether_core::events::{AgentCommand, AgentEvent, Command, ToolEvent, TurnOutcome}; use aether_core::session::{SessionControlEvent, SessionEvent, UserEvent}; @@ -29,6 +28,7 @@ use super::protocol::events::{ AgentExtNotification, map_agent_event_to_session_notification, try_extract_plan_notification, try_into_agent_notification, }; +use super::session_agents::SessionAgents; use super::session_config_state::{SessionConfigState, Switch}; use super::session_store::SessionStore; use super::slash_commands::{expand_slash_command_in_content, send_available_commands}; @@ -115,7 +115,7 @@ pub(crate) struct SessionActorInit { pub repository: Arc, pub oauth_credential_store: Arc, pub active_agent: AgentKey, - pub specs: HashMap, + pub specs: SessionAgents, pub runtime_factory: Arc, pub transcript: Vec, pub modes: Modes, @@ -126,7 +126,7 @@ pub(crate) struct SessionActorInit { /// is serialized through the command channel. pub(crate) struct SessionActor { active_agent: AgentKey, - specs: HashMap, + specs: SessionAgents, runtimes: HashMap, runtime_factory: Arc, runtime_event_tx: mpsc::Sender, diff --git a/crates/aether-cli/src/acp/session_agents.rs b/crates/aether-cli/src/acp/session_agents.rs new file mode 100644 index 000000000..fe173c772 --- /dev/null +++ b/crates/aether-cli/src/acp/session_agents.rs @@ -0,0 +1,31 @@ +use aether_core::agent_spec::AgentSpec; +use aether_project::AgentCatalog; + +use super::agent_key::AgentKey; + +#[derive(Clone)] +pub(crate) struct SessionAgents { + catalog: AgentCatalog, + default_spec: Option, +} + +impl SessionAgents { + pub(crate) fn new(catalog: AgentCatalog) -> Self { + Self { catalog, default_spec: None } + } + + pub(crate) fn catalog(&self) -> &AgentCatalog { + &self.catalog + } + + pub(crate) fn set_default(&mut self, spec: AgentSpec) { + self.default_spec = Some(spec); + } + + pub(crate) fn get(&self, key: &AgentKey) -> Option<&AgentSpec> { + match key { + AgentKey::Default => self.default_spec.as_ref(), + AgentKey::Named(name) => self.catalog.get(name).ok().filter(|spec| spec.exposure.user_invocable), + } + } +} diff --git a/crates/aether-cli/src/acp/session_factory.rs b/crates/aether-cli/src/acp/session_factory.rs index c4c08798d..9b362057e 100644 --- a/crates/aether-cli/src/acp/session_factory.rs +++ b/crates/aether-cli/src/acp/session_factory.rs @@ -3,12 +3,12 @@ use aether_core::agent_spec::AgentSpec; use aether_core::core::AgentDeps; use aether_core::events::DynObserverFactory; use aether_core::session::{SessionEvent, SessionMeta, last_agent_from_events}; +use aether_project::AgentCatalog; use agent_client_protocol::schema::{self as acp, LoadSessionRequest, NewSessionRequest, SessionId}; use agent_client_protocol::{Client, ConnectionTo}; use llm::catalog::{LlmModel, get_local_models}; use llm::types::IsoString; use llm::{ProviderConnectionOverrides, ReasoningEffort}; -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::{error, info, warn}; @@ -18,32 +18,12 @@ use super::agent_runtime::{ProductionRuntimeFactory, RuntimeFactory}; use super::model_config::{Modes, pick_default_model}; use super::protocol::mcp::map_acp_mcp_servers; use super::session_actor::{SessionActor, SessionActorInit, SessionHandle}; +use super::session_agents::SessionAgents; use super::session_config_state::SessionConfigState; use super::session_store::SessionStore; +use crate::resolve::{InitialSessionSelection, resolve_agent_from_catalog}; use crate::settings_args::SettingsSourceArgs; -/// Initial session selection supplied when `aether acp` starts. -#[derive(Clone, Debug, Default)] -pub enum InitialSessionSelection { - #[default] - Default, - Agent(String), - Model { - model: String, - reasoning_effort: Option, - }, -} - -impl InitialSessionSelection { - pub fn agent(name: String) -> Self { - Self::Agent(name) - } - - pub fn model(model: String, reasoning_effort: Option) -> Self { - Self::Model { model, reasoning_effort } - } -} - /// Builds the per-session actor for both new and loaded sessions, resolving /// settings, agent catalog, model discovery, and the runtime factory. pub(crate) struct SessionFactory { @@ -116,7 +96,7 @@ impl SessionFactory { error!("Failed to write session meta: {e}"); } - let runtime_factory = self.production_runtime_factory(args.cwd, args.mcp_servers); + let runtime_factory = self.production_runtime_factory(args.cwd, args.mcp_servers, mode_catalog.specs.catalog()); self.build_session(SessionId::new(session_id), runtime_factory, mode_catalog, resolved, Vec::new(), cx).await } @@ -134,14 +114,21 @@ impl SessionFactory { })?; let mut mode_catalog = self.load_mode_catalog(&args.cwd).await?; - let resolved = self.resolve_loaded_session(&mut mode_catalog, &meta, &events)?; + let resolved = resolve_loaded_session(&mut mode_catalog, &meta, &events)?; - let runtime_factory = self.production_runtime_factory(args.cwd, args.mcp_servers); + let runtime_factory = self.production_runtime_factory(args.cwd, args.mcp_servers, mode_catalog.specs.catalog()); self.build_session(SessionId::new(session_id), runtime_factory, mode_catalog, resolved, events, cx).await } - fn production_runtime_factory(&self, cwd: PathBuf, mcp_servers: Vec) -> Arc { - let deps = AgentDeps::new(Arc::clone(&self.oauth_credential_store), self.observer_factory.clone()); + fn production_runtime_factory( + &self, + cwd: PathBuf, + mcp_servers: Vec, + catalog: &AgentCatalog, + ) -> Arc { + let registry = catalog.registry().clone(); + let deps = AgentDeps::new(Arc::clone(&self.oauth_credential_store), self.observer_factory.clone()) + .with_agent_registry(registry); Arc::new(ProductionRuntimeFactory::new(cwd, map_acp_mcp_servers(mcp_servers), deps)) } @@ -184,84 +171,60 @@ impl SessionFactory { mode_catalog: &mut SessionModeCatalog, default_model: &LlmModel, ) -> Result { - match &self.initial_selection { - InitialSessionSelection::Default => match mode_catalog.modes.first() { - Some(mode) => { - let name = mode.name.clone(); - resolve_named_session(mode_catalog, &name) - } - None => Ok(self.resolve_model_session(mode_catalog, default_model, None)), - }, + let selection = match &self.initial_selection { InitialSessionSelection::Agent(agent) => { if !mode_catalog.modes.iter().any(|mode| mode.name == *agent) { - warn!("Unknown agent `{agent}` requested via --agent"); + warn!("Unknown or unavailable agent `{agent}` requested via --agent"); return Err(acp::Error::invalid_params()); } - resolve_named_session(mode_catalog, agent) + self.initial_selection.clone() } InitialSessionSelection::Model { model, reasoning_effort } => { let model = parse_available_model(model, &mode_catalog.available)?; - Ok(self.resolve_model_session(mode_catalog, &model, *reasoning_effort)) + InitialSessionSelection::Model { model: model.to_string(), reasoning_effort: *reasoning_effort } } - } - } + InitialSessionSelection::Default if mode_catalog.specs.catalog().default_agent().is_none() => { + InitialSessionSelection::Model { model: default_model.to_string(), reasoning_effort: None } + } + InitialSessionSelection::Default => self.initial_selection.clone(), + }; - fn resolve_loaded_session( - &self, - mode_catalog: &mut SessionModeCatalog, - meta: &SessionMeta, - events: &[SessionEvent], - ) -> Result { - if let Some(name) = last_agent_from_events(meta.selected_mode.clone(), events).as_deref() { - return resolve_named_session(mode_catalog, name); + let selected = + resolve_agent_from_catalog(mode_catalog.specs.catalog().clone(), &selection).map_err(|error| { + warn!("Failed to resolve initial agent: {error}"); + acp::Error::invalid_params() + })?; + + if selected.spec.name == "__default__" { + Ok(resolve_model_spec_session(mode_catalog, selected.spec)) + } else { + if !mode_catalog.modes.iter().any(|mode| mode.name == selected.spec.name) { + warn!("Configured default agent `{}` is unavailable", selected.spec.name); + return Err(acp::Error::invalid_params()); + } + resolve_named_session(mode_catalog, &selected.spec.name) } - - let parsed_model: LlmModel = meta.model.parse().map_err(|e: String| { - error!("Failed to parse restored model '{}': {e}", meta.model); - acp::Error::invalid_params() - })?; - Ok(self.resolve_model_session(mode_catalog, &parsed_model, None)) - } - - fn resolve_model_session( - &self, - mode_catalog: &mut SessionModeCatalog, - model: &LlmModel, - reasoning_effort: Option, - ) -> ResolvedSession { - let spec = - self.apply_provider_connection_overrides(AgentSpec::default_spec(model, reasoning_effort, Vec::new())); - let config = SessionConfigState::with_selection(spec.model.clone(), None, spec.reasoning_effort); - mode_catalog.specs.insert(AgentKey::Default, spec); - ResolvedSession { active_agent: AgentKey::Default, config } - } - - fn apply_provider_connection_overrides(&self, mut spec: AgentSpec) -> AgentSpec { - spec.provider_connections.merge(self.provider_connections.clone()); - spec } async fn load_mode_catalog(&self, cwd: &Path) -> Result { - let catalog = self.settings_source.load_agent_catalog(cwd).map_err(|e| { - error!("Failed to load agent catalog: {e}"); - acp::Error::invalid_params() - })?; + let catalog = self + .settings_source + .load_agent_catalog(cwd) + .map_err(|e| { + error!("Failed to load agent catalog: {e}"); + acp::Error::invalid_params() + })? + .with_provider_connections(self.provider_connections.clone()); let available = get_local_models().await; - let user_invocable: Vec = catalog.user_invocable().cloned().collect(); - let modes = Modes::from_specs(&user_invocable, &available); - let specs = user_invocable - .into_iter() - .map(|spec| self.apply_provider_connection_overrides(spec)) - .map(|spec| (AgentKey::Named(spec.name.clone()), spec)) - .collect(); - - Ok(SessionModeCatalog { specs, modes, available }) + let modes = Modes::from_specs(catalog.all(), &available); + + Ok(SessionModeCatalog { specs: SessionAgents::new(catalog), modes, available }) } } struct SessionModeCatalog { - specs: HashMap, + specs: SessionAgents, modes: Modes, available: Vec, } @@ -271,8 +234,39 @@ struct ResolvedSession { config: SessionConfigState, } +fn resolve_loaded_session( + mode_catalog: &mut SessionModeCatalog, + meta: &SessionMeta, + events: &[SessionEvent], +) -> Result { + if let Some(name) = last_agent_from_events(meta.selected_mode.clone(), events).as_deref() { + return resolve_named_session(mode_catalog, name); + } + + let parsed_model: LlmModel = meta.model.parse().map_err(|e: String| { + error!("Failed to parse restored model '{}': {e}", meta.model); + acp::Error::invalid_params() + })?; + Ok(resolve_model_session(mode_catalog, &parsed_model, None)) +} + +fn resolve_model_spec_session(mode_catalog: &mut SessionModeCatalog, spec: AgentSpec) -> ResolvedSession { + let config = SessionConfigState::with_selection(spec.model.clone(), None, spec.reasoning_effort); + mode_catalog.specs.set_default(spec); + ResolvedSession { active_agent: AgentKey::Default, config } +} + +fn resolve_model_session( + mode_catalog: &mut SessionModeCatalog, + model: &LlmModel, + reasoning_effort: Option, +) -> ResolvedSession { + let spec = mode_catalog.specs.catalog().default_spec(model, reasoning_effort); + resolve_model_spec_session(mode_catalog, spec) +} + fn resolve_named_session(mode_catalog: &SessionModeCatalog, name: &str) -> Result { - let spec = mode_catalog.specs.get(&AgentKey::Named(name.to_string())).ok_or_else(|| { + let spec = mode_catalog.specs.get(&AgentKey::Named(name.to_owned())).ok_or_else(|| { error!("Failed to resolve runtime inputs for mode '{name}'"); acp::Error::invalid_params() })?; diff --git a/crates/aether-cli/src/acp/state.rs b/crates/aether-cli/src/acp/state.rs index a7bc33d0a..3837be0f5 100644 --- a/crates/aether-cli/src/acp/state.rs +++ b/crates/aether-cli/src/acp/state.rs @@ -29,8 +29,9 @@ use super::model_config::supports_prompt_audio; use super::protocol::content::map_acp_to_content_blocks; use super::protocol::replay::replay_to_client; use super::session_actor::{ConfigSnapshot, SessionCommand, SessionHandle}; -use super::session_factory::{InitialSessionSelection, SessionFactory}; +use super::session_factory::SessionFactory; use super::session_store::SessionStore; +use crate::resolve::InitialSessionSelection; use crate::settings_args::SettingsSourceArgs; use crate::workspace::{WorkspaceError, WorkspaceManager}; diff --git a/crates/aether-cli/src/acp/testing.rs b/crates/aether-cli/src/acp/testing.rs index 944b245f6..f2eee98a6 100644 --- a/crates/aether-cli/src/acp/testing.rs +++ b/crates/aether-cli/src/acp/testing.rs @@ -5,11 +5,12 @@ use super::error::SessionError; use super::fake_prompt_mcp::FakePromptMcp; use super::model_config::{Modes, ValidatedMode}; use super::session_actor::{SessionActor, SessionActorInit}; +use super::session_agents::SessionAgents; use super::session_config_state::SessionConfigState; -use super::session_factory::InitialSessionSelection; use super::state::{AcpState, AcpStateConfig}; use crate::acp::session_store::SessionStore; use crate::error::CliError; +use crate::resolve::InitialSessionSelection; use crate::settings_args::SettingsSourceArgs; use crate::workspace::WorkspaceManager; use crate::workspace::testing::StdCopyCloner; @@ -22,6 +23,7 @@ use aether_core::events::{AgentEvent, Command, MessageEvent}; use aether_core::mcp::McpSpawnResult; use aether_core::mcp::mcp; use aether_core::session::{SessionControlEvent, SessionEvent, SessionMeta, UserEvent, last_agent_from_events}; +use aether_project::AgentCatalog; use agent_client_protocol::schema::{SessionId, SessionUpdate}; use agent_client_protocol::{Agent, Client, ConnectionTo}; use llm::ProviderConnectionOverrides; @@ -180,7 +182,8 @@ impl AcpTestHarness { model: &str, ) { let model_spec: llm::catalog::LlmModel = "anthropic:claude-sonnet-4-5".parse().expect("test model parses"); - let spec = AgentSpec::default_spec(&model_spec, None, Vec::new()); + let mut specs = SessionAgents::new(AgentCatalog::empty(PathBuf::from("/tmp"))); + specs.set_default(AgentSpec::bare(&model_spec, None, Vec::new())); let factory = Arc::new(StubRuntimeFactory { cwd: PathBuf::from("/tmp"), agent_parts: Mutex::new(Some(StubAgentParts { tx: agent_tx, rx: agent_rx, handle: agent_handle })), @@ -192,7 +195,7 @@ impl AcpTestHarness { repository: self.session_store.clone(), oauth_credential_store: fake_oauth_store(), active_agent: AgentKey::Default, - specs: HashMap::from([(AgentKey::Default, spec)]), + specs, runtime_factory: factory, transcript: Vec::new(), modes: Modes::default(), @@ -254,12 +257,13 @@ impl AcpTestHarness { coder_def.mcp = None; } - let mut specs = HashMap::new(); + let mut catalog_specs = Vec::new(); let mut agents = HashMap::new(); for def in [planner_def, coder_def] { - specs.insert(AgentKey::Named(def.spec.name.clone()), def.spec.clone()); + catalog_specs.push(def.spec.clone()); agents.insert(def.spec.name.clone(), def); } + let specs = SessionAgents::new(AgentCatalog::new(PathBuf::from("/tmp"), catalog_specs, None)); let factory = Arc::new(FakeRuntimeFactory { cwd: PathBuf::from("/tmp"), agents }); let initial_agent = selected_mode.clone().unwrap_or_else(|| "Planner".to_string()); @@ -506,7 +510,7 @@ fn switching_modes() -> Modes { fn fake_agent_spec(name: &str) -> AgentSpec { let model: llm::catalog::LlmModel = "anthropic:claude-sonnet-4-5".parse().expect("test model parses"); - let mut spec = AgentSpec::default_spec(&model, None, vec![Prompt::text(&format!("{name} system prompt"))]); + let mut spec = AgentSpec::bare(&model, None, vec![Prompt::text(&format!("{name} system prompt"))]); spec.name = name.to_string(); spec.description = format!("{name} test agent"); spec.exposure = AgentSpecExposure::user_only(); diff --git a/crates/aether-cli/src/headless/mod.rs b/crates/aether-cli/src/headless/mod.rs index 865f25f60..d0088714a 100644 --- a/crates/aether-cli/src/headless/mod.rs +++ b/crates/aether-cli/src/headless/mod.rs @@ -11,14 +11,14 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::io::{IsTerminal, Read as _, stdin}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::ExitCode; use crate::credentials::oauth_credential_store_from_config; use crate::mcp_config_args::McpConfigArgs; use crate::output::OutputFormat; use crate::provider_connection_args::ProviderConnectionArgs; -use crate::resolve::resolve_agent_spec; +use crate::resolve::{AgentSelectionError, InitialSessionSelection, resolve_agent_from_settings}; use crate::settings_args::SettingsSourceArgs; use aether_auth::OAuthCredentialStorage; use std::sync::Arc; @@ -54,6 +54,7 @@ pub struct RunConfig { pub cwd: PathBuf, pub mcp_config_sources: Vec, pub spec: AgentSpec, + pub agent_catalog: AgentCatalog, pub system_prompt: Option, pub output: OutputFormat, pub verbose: bool, @@ -157,20 +158,24 @@ impl RunConfig { let provider_connections = args.provider_connection.clone().into_overrides(); let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?; let telemetry = settings.telemetry.clone(); - let spec = resolve_spec_from_settings( - args.agent.as_deref(), - args.model.as_deref(), - &cwd, - settings, - provider_connections, - )?; + let selection = match (args.agent, args.model) { + (Some(agent), None) => InitialSessionSelection::Agent(agent), + (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None }, + (None, None) => InitialSessionSelection::Default, + (Some(_), Some(_)) => { + return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string())); + } + }; + let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection) + .map_err(map_selection_error)?; let mcp_config_sources = args.mcp_config.sources(&cwd); Ok(Self { prompt, cwd, mcp_config_sources, - spec, + spec: resolved.spec, + agent_catalog: resolved.catalog, system_prompt: args.system_prompt, output: args.output, verbose: args.verbose, @@ -189,13 +194,16 @@ impl RunConfig { let provider_connections = ProviderConnectionOverrides::new(options.providers.unwrap_or_default()); let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?; let telemetry = settings.telemetry.clone(); - let spec = resolve_spec_from_settings( - options.agent.as_deref(), - options.model.as_deref(), - &cwd, - settings, - provider_connections, - )?; + let selection = match (options.agent, options.model) { + (Some(agent), None) => InitialSessionSelection::Agent(agent), + (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None }, + (None, None) => InitialSessionSelection::Default, + (Some(_), Some(_)) => { + return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string())); + } + }; + let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection) + .map_err(map_selection_error)?; let mcp_config_sources = options .mcp_config .map(|config| serde_json::to_string(&config).expect("mcp config serialize")) @@ -207,7 +215,8 @@ impl RunConfig { prompt, cwd, mcp_config_sources, - spec, + spec: resolved.spec, + agent_catalog: resolved.catalog, system_prompt: options.system_prompt, output: options.output.unwrap_or(OutputFormat::Text), verbose: options.verbose.unwrap_or(false), @@ -236,161 +245,10 @@ fn resolve_prompt(args: &HeadlessArgs) -> Result { } } -fn resolve_spec_from_settings( - agent: Option<&str>, - model: Option<&str>, - cwd: &Path, - settings: AetherSettings, - provider_connections: ProviderConnectionOverrides, -) -> Result { - if agent.is_some() && model.is_some() { - return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string())); - } - - let catalog = AgentCatalog::from_settings_or_empty(cwd, settings)?; - - let mut spec = match model { - Some(m) => { - let parsed = m.parse().map_err(CliError::ModelError)?; - AgentSpec::default_spec(&parsed, None, Vec::new()) - } - None => resolve_agent_spec(&catalog, agent)?, - }; - spec.provider_connections.merge(provider_connections); - Ok(spec) -} - -#[cfg(test)] -mod tests { - use std::fs::{create_dir_all, write}; - - use super::*; - - fn resolve_spec( - agent: Option<&str>, - model: Option<&str>, - cwd: &Path, - settings_source: &SettingsSourceArgs, - provider_connections: ProviderConnectionOverrides, - ) -> Result { - let settings = settings_source.load_settings(cwd)?; - resolve_spec_from_settings(agent, model, cwd, settings, provider_connections) - } - - #[test] - fn resolve_spec_with_named_agent() { - let dir = setup_dir_with_agents(); - let spec = resolve_spec( - Some("beta"), - None, - dir.path(), - &project_settings_args(), - ProviderConnectionOverrides::default(), - ) - .unwrap(); - assert_eq!(spec.name, "beta"); - } - - #[test] - fn resolve_spec_with_model_creates_default() { - let dir = setup_dir_with_agents(); - let spec = resolve_spec( - None, - Some("anthropic:claude-sonnet-4-5"), - dir.path(), - &project_settings_args(), - ProviderConnectionOverrides::default(), - ) - .unwrap(); - assert_eq!(spec.name, "__default__"); - } - - #[test] - fn resolve_spec_defaults_to_first_user_invocable() { - let dir = setup_dir_with_agents(); - let spec = - resolve_spec(None, None, dir.path(), &project_settings_args(), ProviderConnectionOverrides::default()) - .unwrap(); - assert_eq!(spec.name, "alpha"); - } - - #[test] - fn resolve_spec_defaults_to_fallback_without_settings() { - let dir = tempfile::tempdir().unwrap(); - let spec = resolve_spec(None, None, dir.path(), &empty_settings_args(), ProviderConnectionOverrides::default()) - .unwrap(); - assert_eq!(spec.name, "__default__"); - } - - #[test] - fn resolve_spec_rejects_both_agent_and_model() { - let dir = setup_dir_with_agents(); - let err = resolve_spec( - Some("alpha"), - Some("anthropic:claude-sonnet-4-5"), - dir.path(), - &SettingsSourceArgs::default(), - ProviderConnectionOverrides::default(), - ) - .unwrap_err(); - assert!(err.to_string().contains("Cannot specify both"), "unexpected error: {err}"); - } - - #[test] - fn resolve_spec_rejects_invalid_model() { - let dir = tempfile::tempdir().unwrap(); - let err = resolve_spec( - None, - Some("not-a-valid-model"), - dir.path(), - &empty_settings_args(), - ProviderConnectionOverrides::default(), - ) - .unwrap_err(); - assert!(matches!(err, CliError::ModelError(_))); - } - - #[test] - fn resolve_spec_rejects_unknown_agent() { - let dir = setup_dir_with_agents(); - let err = resolve_spec( - Some("nonexistent"), - None, - dir.path(), - &project_settings_args(), - ProviderConnectionOverrides::default(), - ) - .unwrap_err(); - assert!(matches!(err, CliError::AgentError(_))); - } - - fn write_file(dir: &std::path::Path, path: &str, content: &str) { - let full = dir.join(path); - if let Some(parent) = full.parent() { - create_dir_all(parent).unwrap(); - } - write(full, content).unwrap(); - } - - fn project_settings_args() -> SettingsSourceArgs { - SettingsSourceArgs { settings_json: None, settings_file: Some(PathBuf::from(".aether/settings.json")) } - } - - fn empty_settings_args() -> SettingsSourceArgs { - SettingsSourceArgs { settings_json: Some(r#"{"agents":[]}"#.to_string()), settings_file: None } - } - - fn setup_dir_with_agents() -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - write_file(dir.path(), "PROMPT.md", "Be helpful"); - write_file( - dir.path(), - ".aether/settings.json", - r#"{"agents": [ - {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]}, - {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]} - ]}"#, - ); - dir +fn map_selection_error(error: AgentSelectionError) -> CliError { + match error { + AgentSelectionError::Settings(error) => CliError::Settings(error), + AgentSelectionError::Agent(error) => CliError::AgentError(error.to_string()), + AgentSelectionError::Model(error) => CliError::ModelError(error), } } diff --git a/crates/aether-cli/src/headless/run.rs b/crates/aether-cli/src/headless/run.rs index b17766256..5b55b4103 100644 --- a/crates/aether-cli/src/headless/run.rs +++ b/crates/aether-cli/src/headless/run.rs @@ -37,8 +37,10 @@ async fn run_agent(config: RunConfig, telemetry: Option>) spec.prompts.push(Prompt::text(&system_prompt)); } + let registry = config.agent_catalog.registry().clone(); let deps = - AgentDeps::new(config.oauth_credential_store, telemetry.as_ref().map(|runtime| runtime.observer_factory())); + AgentDeps::new(config.oauth_credential_store, telemetry.as_ref().map(|runtime| runtime.observer_factory())) + .with_agent_registry(registry); let (agent, _mcp_snapshot) = RuntimeBuilder::from_spec(config.cwd.clone(), spec) .mcp_sources(config.mcp_config_sources) .agent_deps(deps) diff --git a/crates/aether-cli/src/resolve.rs b/crates/aether-cli/src/resolve.rs index c8b946fa2..9d13ee47c 100644 --- a/crates/aether-cli/src/resolve.rs +++ b/crates/aether-cli/src/resolve.rs @@ -1,89 +1,85 @@ -use crate::error::CliError; use aether_core::agent_spec::AgentSpec; -use aether_project::AgentCatalog; +use aether_project::{AetherSettings, AgentCatalog, SettingsError}; +use llm::{ProviderConnectionOverrides, ReasoningEffort}; +use std::path::Path; +use thiserror::Error; -pub fn resolve_agent_spec(catalog: &AgentCatalog, agent_name: Option<&str>) -> Result { - match agent_name { - Some(name) => catalog.resolve(name).map_err(|e| CliError::AgentError(e.to_string())), +const FALLBACK_MODEL: &str = "anthropic:claude-sonnet-4-5"; - None => { - if let Some(selected) = catalog.default_agent() { - catalog.resolve(&selected.name).map_err(|e| CliError::AgentError(e.to_string())) - } else { - let model = "anthropic:claude-sonnet-4-5".parse().map_err(|e: String| CliError::ModelError(e))?; - - Ok(AgentSpec::default_spec(&model, None, Vec::new())) - } - } - } +#[derive(Clone, Debug, Default)] +pub(crate) enum InitialSessionSelection { + #[default] + Default, + Agent(String), + Model { + model: String, + reasoning_effort: Option, + }, } -#[cfg(test)] -mod tests { - use super::*; - use aether_project::{AetherSettings, AetherSettingsSource, SettingsFileSource}; - - fn write_file(dir: &std::path::Path, path: &str, content: &str) { - let full = dir.join(path); - if let Some(parent) = full.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(full, content).unwrap(); +impl InitialSessionSelection { + pub(crate) fn agent(name: String) -> Self { + Self::Agent(name) } - fn setup_catalog(settings_json: &str) -> (tempfile::TempDir, AgentCatalog) { - let dir = tempfile::tempdir().unwrap(); - write_file(dir.path(), "PROMPT.md", "Be helpful"); - write_file(dir.path(), ".aether/settings.json", settings_json); - let config = AetherSettings::load( - dir.path(), - [AetherSettingsSource::File(SettingsFileSource::new(".aether/settings.json", dir.path()))], - ) - .unwrap(); - let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap(); - (dir, catalog) + pub(crate) fn model(model: String, reasoning_effort: Option) -> Self { + Self::Model { model, reasoning_effort } } +} - #[test] - fn resolve_with_explicit_name() { - let (_dir, catalog) = setup_catalog( - r#"{"agents": [ - {"name": "first", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]}, - {"name": "second", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]} - ]}"#, - ); - let spec = resolve_agent_spec(&catalog, Some("second")).unwrap(); - assert_eq!(spec.name, "second"); - } +pub(crate) struct ResolvedAgentSelection { + pub(crate) spec: AgentSpec, + pub(crate) catalog: AgentCatalog, +} - #[test] - fn resolve_auto_selects_first_user_invocable() { - let (_dir, catalog) = setup_catalog( - r#"{"agents": [ - {"name": "internal", "description": "Internal", "model": "anthropic:claude-sonnet-4-5", "agentInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]}, - {"name": "visible", "description": "Visible", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]} - ]}"#, - ); - let spec = resolve_agent_spec(&catalog, None).unwrap(); - assert_eq!(spec.name, "visible"); - } +#[derive(Debug, Error)] +pub(crate) enum AgentSelectionError { + #[error(transparent)] + Settings(#[from] SettingsError), + #[error("{0}")] + Agent(SettingsError), + #[error("Model error: {0}")] + Model(String), +} - #[test] - fn resolve_falls_back_to_default() { - let dir = tempfile::tempdir().unwrap(); - let catalog = AgentCatalog::empty(dir.path().to_path_buf()); - let spec = resolve_agent_spec(&catalog, None).unwrap(); - assert_eq!(spec.name, "__default__"); - } +pub(crate) fn resolve_agent_from_settings( + cwd: &Path, + settings: AetherSettings, + provider_connections: ProviderConnectionOverrides, + selection: &InitialSessionSelection, +) -> Result { + let catalog = AgentCatalog::from_settings_or_empty(cwd, settings)?.with_provider_connections(provider_connections); + resolve_agent_from_catalog(catalog, selection) +} - #[test] - fn resolve_unknown_name_errors() { - let (_dir, catalog) = setup_catalog( - r#"{"agents": [ - {"name": "alpha", "description": "Alpha", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]} - ]}"#, - ); - let result = resolve_agent_spec(&catalog, Some("nonexistent")); - assert!(result.is_err()); - } +pub(crate) fn resolve_agent_from_catalog( + catalog: AgentCatalog, + selection: &InitialSessionSelection, +) -> Result { + let spec = match selection { + InitialSessionSelection::Agent(name) => catalog.resolve(name).map_err(AgentSelectionError::Agent)?, + InitialSessionSelection::Model { model, reasoning_effort } => { + catalog.default_spec(&model.parse().map_err(AgentSelectionError::Model)?, *reasoning_effort) + } + InitialSessionSelection::Default => match catalog.default_agent() { + Some(spec) => spec.clone(), + None => catalog.default_spec(&FALLBACK_MODEL.parse().map_err(AgentSelectionError::Model)?, None), + }, + }; + + Ok(ResolvedAgentSelection { spec, catalog }) +} + +pub fn resolve_agent_spec( + catalog: &AgentCatalog, + agent_name: Option<&str>, +) -> Result { + let selection = + agent_name.map_or(InitialSessionSelection::Default, |name| InitialSessionSelection::Agent(name.to_string())); + resolve_agent_from_catalog(catalog.clone(), &selection).map(|resolved| resolved.spec).map_err(|error| match error { + AgentSelectionError::Settings(error) | AgentSelectionError::Agent(error) => { + crate::error::CliError::AgentError(error.to_string()) + } + AgentSelectionError::Model(error) => crate::error::CliError::ModelError(error), + }) } diff --git a/crates/aether-cli/src/runtime.rs b/crates/aether-cli/src/runtime.rs index b5676cebe..52a78e8c9 100644 --- a/crates/aether-cli/src/runtime.rs +++ b/crates/aether-cli/src/runtime.rs @@ -6,10 +6,10 @@ use aether_core::mcp::McpBuilder; use aether_core::mcp::McpSpawnResult; use aether_core::mcp::mcp; use aether_core::mcp::run_mcp_task::McpCommand; -use llm::{ChatMessage, LlmModel, ToolDefinition}; +use llm::{ChatMessage, ToolDefinition}; use mcp_servers::McpBuilderExt; use mcp_utils::client::{McpClientEvent, McpConnectionDetails, McpServer, OAuthHandlerFactory}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tokio::sync::mpsc::{Receiver, Sender}; use tokio::task::JoinHandle; use tracing::debug; @@ -38,21 +38,6 @@ pub struct PromptInfo { } impl RuntimeBuilder { - pub fn new(cwd: &Path, model: &str) -> Result { - let cwd = cwd.canonicalize().map_err(CliError::IoError)?; - let parsed_model: LlmModel = model.parse().map_err(CliError::ModelError)?; - let spec = AgentSpec::default_spec(&parsed_model, None, Vec::new()); - - Ok(Self { - cwd, - spec, - mcp_config_sources: Vec::new(), - extra_mcp_servers: Vec::new(), - oauth_applicator: None, - agent_deps: AgentDeps::default(), - }) - } - pub fn from_spec(cwd: PathBuf, spec: AgentSpec) -> Self { Self { cwd, @@ -149,13 +134,14 @@ impl RuntimeBuilder { } async fn spawn_mcp(self) -> Result<(AgentSpec, McpSpawnResult), CliError> { - let mut builder = mcp(&self.cwd).with_agent_deps(self.agent_deps.clone()); + let deps = self.agent_deps.clone(); + let mut builder = mcp(&self.cwd); if let Some(apply_oauth) = self.oauth_applicator { builder = apply_oauth(builder); } - builder = builder.with_builtin_servers(); + builder = builder.with_builtin_servers(deps); if !self.extra_mcp_servers.is_empty() { builder = builder.with_servers(self.extra_mcp_servers); diff --git a/crates/aether-cli/src/show_prompt/run.rs b/crates/aether-cli/src/show_prompt/run.rs index 0c142c491..232150829 100644 --- a/crates/aether-cli/src/show_prompt/run.rs +++ b/crates/aether-cli/src/show_prompt/run.rs @@ -4,7 +4,7 @@ use super::PromptArgs; use crate::error::CliError; use crate::resolve::resolve_agent_spec; use crate::runtime::RuntimeBuilder; -use aether_core::core::Prompt; +use aether_core::core::{AgentDeps, Prompt}; use llm::ToolDefinition; use serde_json::Value; @@ -13,7 +13,9 @@ pub async fn run_prompt(args: PromptArgs) -> Result<(), CliError> { let catalog = args.settings_source.load_agent_catalog(&cwd).map_err(|e| CliError::AgentError(e.to_string()))?; let spec = resolve_agent_spec(&catalog, args.agent.as_deref())?; + let registry = catalog.registry().clone(); let info = RuntimeBuilder::from_spec(cwd.clone(), spec) + .agent_deps(AgentDeps::default().with_agent_registry(registry)) .mcp_sources(args.mcp_config.sources(&cwd)) .build_prompt_info() .await?; diff --git a/crates/aether-core/src/agent_spec.rs b/crates/aether-core/src/agent_spec.rs index b5727254d..62fe72776 100644 --- a/crates/aether-core/src/agent_spec.rs +++ b/crates/aether-core/src/agent_spec.rs @@ -67,8 +67,9 @@ pub struct AgentSpec { } impl AgentSpec { - /// Create a default (no-mode) agent spec with the provided prompts. - pub fn default_spec(model: &LlmModel, reasoning_effort: Option, prompts: Vec) -> Self { + /// Create a bare no-mode spec without catalog defaults or runtime policy. + /// Production callers should prefer their catalog's `default_spec` API. + pub fn bare(model: &LlmModel, reasoning_effort: Option, prompts: Vec) -> Self { Self { name: "__default__".to_string(), description: "Default agent".to_string(), @@ -228,7 +229,7 @@ mod tests { fn default_spec_has_expected_fields() { let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap(); let prompts = vec![Prompt::file(PathBuf::from("/tmp/BASE.md"), PathBuf::from("/tmp"))]; - let spec = AgentSpec::default_spec(&model, None, prompts.clone()); + let spec = AgentSpec::bare(&model, None, prompts.clone()); assert_eq!(spec.name, "__default__"); assert_eq!(spec.description, "Default agent"); diff --git a/crates/aether-core/src/core/agent_deps.rs b/crates/aether-core/src/core/agent_deps.rs index 29eaf1ec4..df92de3d7 100644 --- a/crates/aether-core/src/core/agent_deps.rs +++ b/crates/aether-core/src/core/agent_deps.rs @@ -1,3 +1,4 @@ +use crate::core::AgentRegistry; use crate::events::{AgentObserver, DynObserverFactory, TraceContext}; use aether_auth::OAuthCredentialStorage; use std::sync::Arc; @@ -12,6 +13,7 @@ pub struct AgentDeps { /// Remote trace these agents continue, set by whoever handled the request /// that spawned them. pub parent_trace_context: Option, + pub agent_registry: AgentRegistry, } impl AgentDeps { @@ -19,7 +21,12 @@ impl AgentDeps { oauth_credential_store: Arc, observer_factory: Option, ) -> Self { - Self { oauth_credential_store: Some(oauth_credential_store), observer_factory, parent_trace_context: None } + Self { + oauth_credential_store: Some(oauth_credential_store), + observer_factory, + parent_trace_context: None, + agent_registry: AgentRegistry::default(), + } } /// Continue `parent`'s trace in every agent built from these deps. @@ -28,6 +35,11 @@ impl AgentDeps { self } + pub fn with_agent_registry(mut self, registry: AgentRegistry) -> Self { + self.agent_registry = registry; + self + } + /// A fresh observer isolated to one agent, if a factory is configured. pub fn observer(&self) -> Option> { self.observer_factory.as_ref().map(|factory| factory.agent(self.parent_trace_context.as_ref())) diff --git a/crates/aether-core/src/core/agent_registry.rs b/crates/aether-core/src/core/agent_registry.rs new file mode 100644 index 000000000..15e895f98 --- /dev/null +++ b/crates/aether-core/src/core/agent_registry.rs @@ -0,0 +1,72 @@ +use crate::agent_spec::AgentSpec; +use std::sync::Arc; +use thiserror::Error; + +/// A cheap-to-clone registry of resolved agent specifications. +#[derive(Clone, Debug, Default)] +pub struct AgentRegistry { + specs: Arc<[AgentSpec]>, +} + +impl AgentRegistry { + pub fn new(specs: Vec) -> Self { + Self { specs: specs.into() } + } + + pub fn all(&self) -> &[AgentSpec] { + &self.specs + } + + pub fn get(&self, name: &str) -> Option<&AgentSpec> { + self.specs.iter().find(|spec| spec.name == name) + } + + pub fn agent_invocable(&self) -> impl Iterator { + self.specs.iter().filter(|spec| spec.exposure.agent_invocable) + } + + /// Resolve an agent that is exposed for delegation. + pub fn resolve_agent_invocable(&self, name: &str) -> Result { + let spec = self.get(name).ok_or_else(|| AgentRegistryError::NotFound { name: name.to_string() })?; + if !spec.exposure.agent_invocable { + return Err(AgentRegistryError::NotAgentInvocable { name: name.to_string() }); + } + Ok(spec.clone()) + } +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum AgentRegistryError { + #[error("Agent '{name}' not found")] + NotFound { name: String }, + #[error("Agent '{name}' is not agent-invocable")] + NotAgentInvocable { name: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent_spec::AgentSpecExposure; + + #[test] + fn delegation_resolution_distinguishes_missing_and_unexposed_agents() { + let model = "anthropic:claude-sonnet-4-5".parse().unwrap(); + let mut delegate = AgentSpec::bare(&model, None, Vec::new()); + delegate.name = "delegate".to_string(); + delegate.exposure = AgentSpecExposure::agent_only(); + let mut user_only = AgentSpec::bare(&model, None, Vec::new()); + user_only.name = "user-only".to_string(); + user_only.exposure = AgentSpecExposure::user_only(); + let registry = AgentRegistry::new(vec![delegate, user_only]); + + assert_eq!(registry.resolve_agent_invocable("delegate").unwrap().name, "delegate"); + assert!(matches!( + registry.resolve_agent_invocable("user-only"), + Err(AgentRegistryError::NotAgentInvocable { name }) if name == "user-only" + )); + assert!(matches!( + registry.resolve_agent_invocable("missing"), + Err(AgentRegistryError::NotFound { name }) if name == "missing" + )); + } +} diff --git a/crates/aether-core/src/core/mod.rs b/crates/aether-core/src/core/mod.rs index 34585d74f..8e0311628 100644 --- a/crates/aether-core/src/core/mod.rs +++ b/crates/aether-core/src/core/mod.rs @@ -1,6 +1,7 @@ mod agent; mod agent_builder; mod agent_deps; +mod agent_registry; mod error; mod prompt; mod prompt_cache_key; @@ -10,6 +11,7 @@ pub use crate::events::{AgentCommand, AgentEvent, Command, UserCommand}; pub use agent::*; pub use agent_builder::*; pub use agent_deps::*; +pub use agent_registry::*; pub use error::*; pub use prompt::*; pub use retry_config::RetryConfig; diff --git a/crates/aether-project/src/agent_catalog.rs b/crates/aether-project/src/agent_catalog.rs index b8f3b1155..f7d4d3c3e 100644 --- a/crates/aether-project/src/agent_catalog.rs +++ b/crates/aether-project/src/agent_catalog.rs @@ -2,22 +2,21 @@ use crate::aether_settings::{project_settings_exist, user_settings_exist}; use crate::error::SettingsError; use crate::{AetherSettings, AgentConfig, McpFileSpec, McpSourceSpec}; use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, McpConfigSource}; -use aether_core::core::Prompt; -use llm::ProviderConnectionOverrides; -use llm::catalog::{ModelSpec, ModelSpecError}; +use aether_core::core::{AgentRegistry, Prompt}; +use llm::catalog::{LlmModel, ModelSpec, ModelSpecError}; +use llm::{ProviderConnectionOverrides, ReasoningEffort}; use mcp_utils::client::McpConfig; use std::collections::HashSet; use std::path::{Path, PathBuf}; use utils::variables::VarError; /// A resolved catalog of agents from a project. -/// -/// This type owns project-relative resolution context. #[derive(Debug, Clone)] pub struct AgentCatalog { project_root: PathBuf, - specs: Vec, + registry: AgentRegistry, selected_agent: Option, + provider_connections: ProviderConnectionOverrides, } impl AgentCatalog { @@ -38,7 +37,7 @@ impl AgentCatalog { pub fn from_settings_or_empty(project_root: &Path, settings: AetherSettings) -> Result { if settings.agents.is_empty() { - return Ok(Self::empty(project_root.to_path_buf())); + return Ok(Self::with_defaults(project_root.to_path_buf(), Vec::new(), None, settings.providers)); } Self::from_settings(project_root, settings) } @@ -47,6 +46,7 @@ impl AgentCatalog { validate_selected_agent(&settings)?; let selected_agent = settings.agent.as_deref().map(str::trim).filter(|name| !name.is_empty()).map(str::to_string); + let provider_connections = settings.providers.clone(); let defaults = AgentDefaults { prompts: settings.prompts, mcps: settings.mcps, providers: settings.providers }; let mut seen_names = HashSet::new(); let mut specs = Vec::with_capacity(settings.agents.len()); @@ -54,11 +54,33 @@ impl AgentCatalog { specs.push(resolve_agent_entry(project_root, entry, &defaults, index, &mut seen_names)?); } - Ok(Self::new(project_root.to_path_buf(), specs, selected_agent)) + Ok(Self::with_defaults(project_root.to_path_buf(), specs, selected_agent, provider_connections)) } - pub(crate) fn new(project_root: PathBuf, specs: Vec, selected_agent: Option) -> Self { - Self { project_root, specs, selected_agent } + pub fn new(project_root: PathBuf, specs: Vec, selected_agent: Option) -> Self { + Self::with_defaults(project_root, specs, selected_agent, ProviderConnectionOverrides::default()) + } + + #[must_use] + pub fn with_provider_connections(mut self, overrides: ProviderConnectionOverrides) -> Self { + if overrides.is_empty() { + return self; + } + + let specs = self + .registry + .all() + .iter() + .cloned() + .map(|mut spec| { + spec.provider_connections.merge(overrides.clone()); + spec + }) + .collect(); + + self.registry = AgentRegistry::new(specs); + self.provider_connections.merge(overrides); + self } /// Create an empty catalog for a project with no settings. @@ -73,7 +95,7 @@ impl AgentCatalog { /// Get all agent specs in the catalog. pub fn all(&self) -> &[AgentSpec] { - &self.specs + self.registry.all() } pub fn selected_agent(&self) -> Option<&str> { @@ -81,28 +103,32 @@ impl AgentCatalog { } pub fn default_agent(&self) -> Option<&AgentSpec> { - self.selected_agent - .as_deref() - .and_then(|name| self.specs.iter().find(|spec| spec.name == name)) - .or_else(|| self.user_invocable().next()) + self.selected_agent.as_deref().and_then(|name| self.registry.get(name)).or_else(|| self.user_invocable().next()) } /// Get a specific agent by name. pub fn get(&self, name: &str) -> Result<&AgentSpec, SettingsError> { - self.specs - .iter() - .find(|spec| spec.name == name) - .ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() }) + self.registry.get(name).ok_or_else(|| SettingsError::AgentNotFound { name: name.to_string() }) } /// Iterate over user-invocable agents. pub fn user_invocable(&self) -> impl Iterator { - self.specs.iter().filter(|s| s.exposure.user_invocable) + self.registry.all().iter().filter(|s| s.exposure.user_invocable) } /// Iterate over agent-invocable agents. pub fn agent_invocable(&self) -> impl Iterator { - self.specs.iter().filter(|s| s.exposure.agent_invocable) + self.registry.agent_invocable() + } + + pub fn registry(&self) -> &AgentRegistry { + &self.registry + } + + pub fn default_spec(&self, model: &LlmModel, reasoning_effort: Option) -> AgentSpec { + let mut spec = AgentSpec::bare(model, reasoning_effort, Vec::new()); + spec.provider_connections.merge(self.provider_connections.clone()); + spec } /// Resolve and return a named agent spec ready for runtime use. @@ -111,6 +137,17 @@ impl AgentCatalog { } } +impl AgentCatalog { + fn with_defaults( + project_root: PathBuf, + specs: Vec, + selected_agent: Option, + provider_connections: ProviderConnectionOverrides, + ) -> Self { + Self { project_root, registry: AgentRegistry::new(specs), selected_agent, provider_connections } + } +} + struct AgentDefaults { prompts: Vec, mcps: Vec, @@ -298,6 +335,113 @@ mod tests { }) } + fn overrides(url: &str) -> ProviderConnectionOverrides { + ProviderConnectionOverrides::new(std::collections::BTreeMap::from([( + "anthropic".to_string(), + llm::ProviderConnectionOverride::url(url), + )])) + } + + fn base_url(spec: &AgentSpec) -> Option { + spec.provider_connections.config_for("anthropic").base_url + } + + #[test] + fn provider_connections_reach_every_agent_not_just_the_selected_one() { + let dir = create_temp_project(); + let catalog = AgentCatalog::new( + dir.path().to_path_buf(), + vec![make_spec("planner", AgentSpecExposure::both()), make_spec("worker", AgentSpecExposure::agent_only())], + None, + ) + .with_provider_connections(overrides("https://runtime.test")); + + for name in ["planner", "worker"] { + assert_eq!(base_url(catalog.get(name).unwrap()).as_deref(), Some("https://runtime.test"), "agent {name}"); + } + } + + #[test] + fn runtime_provider_connections_win_over_settings_declared_ones() { + let dir = create_temp_project(); + let mut declared = make_spec("planner", AgentSpecExposure::both()); + declared.provider_connections = overrides("https://from-settings.test"); + + let catalog = AgentCatalog::new(dir.path().to_path_buf(), vec![declared], None) + .with_provider_connections(overrides("https://runtime.test")); + + assert_eq!(base_url(catalog.get("planner").unwrap()).as_deref(), Some("https://runtime.test")); + } + + #[test] + fn default_spec_inherits_runtime_provider_connections() { + let dir = create_temp_project(); + let catalog = + AgentCatalog::empty(dir.path().to_path_buf()).with_provider_connections(overrides("https://runtime.test")); + + let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None); + + assert_eq!(spec.name, "__default__"); + assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test")); + } + + #[test] + fn settings_provider_connections_apply_to_explicit_model_specs() { + let dir = create_temp_project(); + write_file(dir.path(), "BASE.md", "Base instructions"); + let settings = AetherSettings { + agents: vec![AgentConfig { + name: "planner".to_string(), + description: "Planner agent".to_string(), + model: "anthropic:claude-sonnet-4-5".to_string(), + user_invocable: true, + prompts: vec![crate::PromptSource::file("BASE.md")], + ..AgentConfig::default() + }], + providers: overrides("https://settings.test"), + ..AetherSettings::default() + }; + let catalog = AgentCatalog::from_settings_or_empty(dir.path(), settings).unwrap(); + + let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None); + + assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test")); + } + + #[test] + fn settings_provider_connections_apply_to_fallback_specs_without_agents() { + let dir = create_temp_project(); + let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() }; + let catalog = AgentCatalog::from_settings_or_empty(dir.path(), settings).unwrap(); + + let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None); + + assert_eq!(base_url(&spec).as_deref(), Some("https://settings.test")); + } + + #[test] + fn runtime_provider_connections_override_settings_for_default_specs() { + let dir = create_temp_project(); + let settings = AetherSettings { providers: overrides("https://settings.test"), ..AetherSettings::default() }; + let catalog = AgentCatalog::from_settings_or_empty(dir.path(), settings) + .unwrap() + .with_provider_connections(overrides("https://runtime.test")); + + let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None); + + assert_eq!(base_url(&spec).as_deref(), Some("https://runtime.test")); + } + + #[test] + fn default_spec_without_overrides_leaves_connections_unset() { + let dir = create_temp_project(); + let catalog = AgentCatalog::empty(dir.path().to_path_buf()); + + let spec = catalog.default_spec(&"anthropic:claude-sonnet-4-5".parse().unwrap(), None); + + assert_eq!(base_url(&spec), None); + } + #[test] fn user_invocable_filters_correctly() { let dir = create_temp_project(); diff --git a/crates/mcp-servers/README.md b/crates/mcp-servers/README.md index ba8a3c627..30b76530d 100644 --- a/crates/mcp-servers/README.md +++ b/crates/mcp-servers/README.md @@ -97,7 +97,7 @@ let builder = mcp() async move { TasksMcp::from_args(args).unwrap().into_dyn() }.boxed() })) .register_in_memory_server("subagents", Box::new(|args| { - async move { SubAgentsMcp::from_args(args).unwrap().into_dyn() }.boxed() + async move { SubAgentsMcp::standalone_from_args(args).unwrap().into_dyn() }.boxed() })) .register_in_memory_server("plan", Box::new(|_args| { async move { PlanMcp::new().into_dyn() }.boxed() diff --git a/crates/mcp-servers/src/bin/stdio.rs b/crates/mcp-servers/src/bin/stdio.rs index 490d31c8f..541d19d29 100644 --- a/crates/mcp-servers/src/bin/stdio.rs +++ b/crates/mcp-servers/src/bin/stdio.rs @@ -62,7 +62,7 @@ async fn main() -> Result<(), StdioError> { serve_stdio(server).await } "subagents" => { - let server = SubAgentsMcp::from_args(cli.args).map_err(StdioError::ServerArgs)?; + let server = SubAgentsMcp::standalone_from_args(cli.args).map_err(StdioError::ServerArgs)?; serve_stdio(server).await } "survey" => { diff --git a/crates/mcp-servers/src/docs/mcp_builder_ext.md b/crates/mcp-servers/src/docs/mcp_builder_ext.md index 3c2cf763c..c453aa8e6 100644 --- a/crates/mcp-servers/src/docs/mcp_builder_ext.md +++ b/crates/mcp-servers/src/docs/mcp_builder_ext.md @@ -2,6 +2,8 @@ Extension trait that registers all built-in MCP server factories onto an [`McpBu Call [`with_builtin_servers`](McpBuilderExt::with_builtin_servers) to register in-memory server factories for all built-in servers (coding, skills, subagents, survey, plan, tasks). Their paths are resolved against the builder's root directory (set via [`mcp`](aether_core::mcp::mcp)). After registration, load an `mcp.json` config to control which servers are actually instantiated. +The supplied [`AgentDeps`](aether_core::core::AgentDeps) are installed on the builder and captured by the embedded servers, so registration cannot be ordered incorrectly. + # Usage ```rust,ignore @@ -9,7 +11,7 @@ use mcp_servers::McpBuilderExt; use aether_core::mcp::mcp; let builder = mcp("/my/project") - .with_builtin_servers() + .with_builtin_servers(deps) .from_json_files(&["mcp.json"]) .await .unwrap(); diff --git a/crates/mcp-servers/src/docs/subagents_mcp.md b/crates/mcp-servers/src/docs/subagents_mcp.md index cb398a7cb..902dfb0f3 100644 --- a/crates/mcp-servers/src/docs/subagents_mcp.md +++ b/crates/mcp-servers/src/docs/subagents_mcp.md @@ -1,17 +1,18 @@ MCP server for spawning and orchestrating concurrent sub-agents. -Sub-agents are independent agent instances that run in parallel, each with their own tool set and conversation context. This server discovers available agent configurations from the project's `.aether/settings.json` and provides a tool to spawn batches of them. +Sub-agents are independent agent instances that run in parallel, each with their own tool set and conversation context. Embedded servers use the active runtime agent catalog supplied by Aether. Standalone servers can discover agent configurations from a project's `.aether/settings.json`. # Construction ```rust,ignore use mcp_servers::SubAgentsMcp; -// From project root (loads agent catalog from .aether/settings.json) -let server = SubAgentsMcp::from_project_root("/my/project".into()).unwrap(); +// Embedded: Aether supplies the complete runtime dependencies +let server = SubAgentsMcp::embedded_from_args(args, "/my/project".as_ref(), deps).unwrap(); -// From CLI args -let server = SubAgentsMcp::from_args(vec!["--project-root".into(), ".".into()]).unwrap(); +// Standalone: discovers the registry from .aether/settings.json +let server = SubAgentsMcp::standalone("/my/project".into()).unwrap(); +let server = SubAgentsMcp::standalone_from_args(vec!["--project-root".into(), ".".into()]).unwrap(); ``` # Tools provided @@ -20,4 +21,6 @@ let server = SubAgentsMcp::from_args(vec!["--project-root".into(), ".".into()]). # Agent catalog -Agent configurations are discovered from `.aether/settings.json` in the project root. Each agent definition specifies a name, model, system prompt, and available tools. +Delegation targets come from the [`AgentRegistry`](aether_core::core::AgentRegistry) in the [`AgentDeps`](aether_core::core::AgentDeps) passed to [`SubAgentsMcp::embedded`](crate::SubAgentsMcp::embedded). Aether resolves the registry once at startup and threads it down through the deps, so SDK settings and other explicit settings sources stay authoritative at every delegation level — including sub-agents that themselves delegate. Embedded servers never reload agent definitions from the workspace. + +Standalone construction through [`SubAgentsMcp::standalone`](crate::SubAgentsMcp::standalone) or CLI arguments has no host to inherit from, so it discovers agents from `.aether/settings.json` in the project root and seeds its own deps with them. Each definition specifies a name, model, system prompt, and available tools. diff --git a/crates/mcp-servers/src/setup.rs b/crates/mcp-servers/src/setup.rs index 5e74c7367..e1a2231d6 100644 --- a/crates/mcp-servers/src/setup.rs +++ b/crates/mcp-servers/src/setup.rs @@ -12,7 +12,7 @@ use tracing::{debug, warn}; pub trait McpBuilderExt { /// Registers all built-in in-memory MCP server factories, resolving their /// paths against the builder's configured root directory. - fn with_builtin_servers(self) -> Self; + fn with_builtin_servers(self, agent_deps: AgentDeps) -> Self; } #[derive(Clone)] @@ -28,101 +28,103 @@ impl BuiltinServerContext { } impl McpBuilderExt for McpBuilder { - fn with_builtin_servers(self) -> Self { - let context = BuiltinServerContext { root_dir: self.root_dir().to_path_buf(), agent_deps: self.agent_deps() }; + fn with_builtin_servers(self, agent_deps: AgentDeps) -> Self { + let context = BuiltinServerContext { root_dir: self.root_dir().to_path_buf(), agent_deps: agent_deps.clone() }; let coding_context = context.clone(); let skills_context = context.clone(); let subagents_context = context.clone(); let plan_context = context.clone(); let tasks_context = context; - self.register_in_memory_server( - "coding", - Box::new(move |args, _input| { - let context = coding_context.clone(); - async move { - let parsed = match CodingMcpArgs::from_args(args) { - Ok(args) => args, - Err(e) => { - warn!("CodingMcp args parse failed: {e}, using defaults"); - CodingMcpArgs::default() - } - }; - let CodingMcpArgs { permission_mode, mut rules_dirs, disable_lsp, root_dir: arg_root_dir } = parsed; - let root_dir = arg_root_dir.map_or_else(|| context.root_dir.clone(), |path| context.resolve(path)); - rules_dirs = rules_dirs.into_iter().map(|path| resolve_path(&root_dir, path)).collect(); - debug!( - "CodingMcp created, disable_lsp={}, permission_mode={:?}, rules_dirs={}", - disable_lsp, - permission_mode, - rules_dirs.len() - ); - let server = CodingMcp::with_tools(DefaultCodingTools::new()) - .with_rules_dirs(rules_dirs) - .with_root_dir(root_dir.clone()) - .with_permission_mode(permission_mode); - let server = if disable_lsp { server } else { server.with_lsp(root_dir) }; - server.into_dyn() - } - .boxed() - }), - ) - .register_in_memory_server( - "skills", - Box::new(move |args, _input| { - let context = skills_context.clone(); - async move { - SkillsMcp::from_args_with_base_dir(args, &context.root_dir) - .expect("Failed to parse SkillsMcp args") - .into_dyn() - } - .boxed() - }), - ) - .register_in_memory_server( - "subagents", - Box::new(move |args, _input| { - let context = subagents_context.clone(); - async move { - SubAgentsMcp::from_args_with_default_project_root(args, &context.root_dir) - .expect("Failed to parse SubAgentsMcp args") - .with_agent_deps(context.agent_deps) - .into_dyn() - } - .boxed() - }), - ) - .register_in_memory_server( - "survey", - Box::new(|_args, _input| async move { SurveyMcp::new().into_dyn() }.boxed()), - ) - .register_in_memory_server( - "plan", - Box::new(move |args, _input| { - let default_plans_dir = plan_context.root_dir.join(DEFAULT_PLANS_DIR); - let root_dir = plan_context.root_dir.clone(); - async move { - PlanMcp::from_args_with_base_dir(args, default_plans_dir, &root_dir) - .expect("Failed to parse PlanMcp args") - .into_dyn() - } - .boxed() - }), - ) - .register_in_memory_server( - "tasks", - Box::new(move |args, _input| { - let context = tasks_context.clone(); - async move { - TasksMcp::from_args_with_base_dir(args, &context.root_dir) - .unwrap_or_else(|e| { - tracing::warn!("Failed to parse TasksMcp args: {e}, using defaults"); - TasksMcp::new() - }) - .into_dyn() - } - .boxed() - }), - ) + self.with_agent_deps(agent_deps) + .register_in_memory_server( + "coding", + Box::new(move |args, _input| { + let context = coding_context.clone(); + async move { + let parsed = match CodingMcpArgs::from_args(args) { + Ok(args) => args, + Err(e) => { + warn!("CodingMcp args parse failed: {e}, using defaults"); + CodingMcpArgs::default() + } + }; + let CodingMcpArgs { permission_mode, mut rules_dirs, disable_lsp, root_dir: arg_root_dir } = + parsed; + let root_dir = + arg_root_dir.map_or_else(|| context.root_dir.clone(), |path| context.resolve(path)); + rules_dirs = rules_dirs.into_iter().map(|path| resolve_path(&root_dir, path)).collect(); + debug!( + "CodingMcp created, disable_lsp={}, permission_mode={:?}, rules_dirs={}", + disable_lsp, + permission_mode, + rules_dirs.len() + ); + let server = CodingMcp::with_tools(DefaultCodingTools::new()) + .with_rules_dirs(rules_dirs) + .with_root_dir(root_dir.clone()) + .with_permission_mode(permission_mode); + let server = if disable_lsp { server } else { server.with_lsp(root_dir) }; + server.into_dyn() + } + .boxed() + }), + ) + .register_in_memory_server( + "skills", + Box::new(move |args, _input| { + let context = skills_context.clone(); + async move { + SkillsMcp::from_args_with_base_dir(args, &context.root_dir) + .expect("Failed to parse SkillsMcp args") + .into_dyn() + } + .boxed() + }), + ) + .register_in_memory_server( + "subagents", + Box::new(move |args, _input| { + let context = subagents_context.clone(); + async move { + SubAgentsMcp::embedded_from_args(args, &context.root_dir, context.agent_deps) + .expect("Failed to parse SubAgentsMcp args") + .into_dyn() + } + .boxed() + }), + ) + .register_in_memory_server( + "survey", + Box::new(|_args, _input| async move { SurveyMcp::new().into_dyn() }.boxed()), + ) + .register_in_memory_server( + "plan", + Box::new(move |args, _input| { + let default_plans_dir = plan_context.root_dir.join(DEFAULT_PLANS_DIR); + let root_dir = plan_context.root_dir.clone(); + async move { + PlanMcp::from_args_with_base_dir(args, default_plans_dir, &root_dir) + .expect("Failed to parse PlanMcp args") + .into_dyn() + } + .boxed() + }), + ) + .register_in_memory_server( + "tasks", + Box::new(move |args, _input| { + let context = tasks_context.clone(); + async move { + TasksMcp::from_args_with_base_dir(args, &context.root_dir) + .unwrap_or_else(|e| { + tracing::warn!("Failed to parse TasksMcp args: {e}, using defaults"); + TasksMcp::new() + }) + .into_dyn() + } + .boxed() + }), + ) } } diff --git a/crates/mcp-servers/src/subagents/server.rs b/crates/mcp-servers/src/subagents/server.rs index 25013aedb..98a3b5ef8 100644 --- a/crates/mcp-servers/src/subagents/server.rs +++ b/crates/mcp-servers/src/subagents/server.rs @@ -39,63 +39,53 @@ impl SubAgentsMcpArgs { Self::try_parse_from(full_args).map_err(ServerInitError::InvalidArgs) } + + /// The project root to serve, falling back to `default_root` when the + /// caller did not name one. + fn resolve_root(self, default_root: &Path) -> PathBuf { + self.project_root.map_or_else(|| default_root.to_path_buf(), |path| resolve_path(default_root, path)) + } } #[doc = include_str!("../docs/subagents_mcp.md")] #[derive(Clone)] pub struct SubAgentsMcp { - catalog: AgentCatalog, tool_router: ToolRouter, project_root: PathBuf, agent_deps: AgentDeps, } impl SubAgentsMcp { - pub fn from_project_root(project_root: PathBuf) -> Result { - let settings = AetherSettings::load_default(&project_root) - .map_err(|e| ServerInitError::Other(format!("Failed to load agents: {e}")))?; - let catalog = if settings.agents.is_empty() { - AgentCatalog::empty(project_root.clone()) - } else { - AgentCatalog::from_settings(&project_root, settings) - .map_err(|e| ServerInitError::Other(format!("Failed to load agents: {e}")))? - }; - Ok(Self::new(catalog, project_root)) - } - - pub fn new(catalog: AgentCatalog, project_root: PathBuf) -> Self { - Self { catalog, tool_router: Self::tool_router(), project_root, agent_deps: AgentDeps::default() } + pub fn embedded(project_root: PathBuf, agent_deps: AgentDeps) -> Self { + Self { tool_router: Self::tool_router(), project_root, agent_deps } } - /// Cross-cutting dependencies (OAuth credentials, observers) passed to - /// every sub-agent this server spawns. - pub fn with_agent_deps(mut self, deps: AgentDeps) -> Self { - self.agent_deps = deps; - self + pub fn embedded_from_args( + args: Vec, + base_dir: &Path, + agent_deps: AgentDeps, + ) -> Result { + Ok(Self::embedded(SubAgentsMcpArgs::from_args(args)?.resolve_root(base_dir), agent_deps)) } - pub fn from_args(args: Vec) -> Result { - let parsed_args = SubAgentsMcpArgs::from_args(args)?; - let project_root = parsed_args.project_root.unwrap_or_else(|| PathBuf::from(".")); - Self::from_project_root(project_root) + pub fn standalone(project_root: PathBuf) -> Result { + let settings = AetherSettings::load_default(&project_root) + .map_err(|e| ServerInitError::Other(format!("Failed to load agents: {e}")))?; + let catalog = AgentCatalog::from_settings_or_empty(&project_root, settings) + .map_err(|e| ServerInitError::Other(format!("Failed to load agents: {e}")))?; + let registry = catalog.registry().clone(); + Ok(Self::embedded(project_root, AgentDeps::default().with_agent_registry(registry))) } - pub fn from_args_with_default_project_root( - args: Vec, - default_root: &Path, - ) -> Result { - let parsed_args = SubAgentsMcpArgs::from_args(args)?; - let project_root = parsed_args - .project_root - .map_or_else(|| default_root.to_path_buf(), |path| resolve_path(default_root, path)); - Self::from_project_root(project_root) + pub fn standalone_from_args(args: Vec) -> Result { + Self::standalone(SubAgentsMcpArgs::from_args(args)?.resolve_root(Path::new("."))) } fn build_instructions(&self) -> String { let mut instructions = include_str!("./instructions.md").to_string(); - let invocable: Vec<_> = self.catalog.agent_invocable().collect(); + let mut invocable = self.agent_deps.agent_registry.agent_invocable().peekable(); - if invocable.is_empty() { + if invocable.peek().is_none() { instructions.push_str( "\n\n**No sub-agents are currently available.** \ The spawn_subagent tool has no registered agents and should not be called.", @@ -162,7 +152,7 @@ impl SubAgentsMcp { context: &RequestContext, parent_trace_context: Option, ) -> Result { - if !args.tasks.is_empty() && self.catalog.agent_invocable().next().is_none() { + if !args.tasks.is_empty() && self.agent_deps.agent_registry.agent_invocable().next().is_none() { return Err("No agent-invocable sub-agents are registered in this project. \ The spawn_subagent tool is not usable — do not call it again." .to_string()); @@ -206,8 +196,7 @@ impl SubAgentsMcp { }; let deps = self.agent_deps.clone().with_parent_trace_context(parent_trace_context); - let executor = AgentExecutor::new(self.catalog.clone(), self.project_root.clone(), deps) - .with_progress_callback(progress_callback); + let executor = AgentExecutor::new(self.project_root.clone(), deps).with_progress_callback(progress_callback); Ok(executor.execute_tasks(args.tasks).await) } diff --git a/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs b/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs index a98e881b4..1ded64be1 100644 --- a/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs +++ b/crates/mcp-servers/src/subagents/tools/spawn_subagent/mod.rs @@ -5,7 +5,6 @@ use aether_core::{ events::{AgentEvent, Command, MessageEvent, TurnEvent, TurnOutcome, UserCommand}, mcp::{McpSpawnResult, mcp, run_mcp_task::McpCommand}, }; -use aether_project::AgentCatalog; use llm::ToolDefinition; use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta}; use schemars::JsonSchema; @@ -154,16 +153,16 @@ pub type ProgressCallback = Box; /// Executor for spawning and running sub-agents #[derive(Clone)] pub struct AgentExecutor { - catalog: Arc, progress_callback: Option>, project_root: PathBuf, deps: AgentDeps, } impl AgentExecutor { - /// Create a new `AgentExecutor` with the given agent catalog and project root. - pub fn new(catalog: AgentCatalog, project_root: PathBuf, deps: AgentDeps) -> Self { - Self { catalog: Arc::new(catalog), progress_callback: None, project_root, deps } + /// Create a new `AgentExecutor` rooted at `project_root`. The agents it may + /// spawn come from `deps`, so nested delegation sees the same catalog. + pub fn new(project_root: PathBuf, deps: AgentDeps) -> Self { + Self { progress_callback: None, project_root, deps } } /// Set a callback for receiving progress updates during agent execution @@ -219,11 +218,11 @@ impl AgentExecutor { let agent_name = task.agent_name.clone(); let result: Result = async { - let mut spec = self.catalog.resolve(&task.agent_name).map_err(|e| e.to_string())?; - - if !spec.exposure.agent_invocable { - return Err(format!("Agent '{}' is not agent-invocable", task.agent_name)); - } + let mut spec = self + .deps + .agent_registry + .resolve_agent_invocable(&task.agent_name) + .map_err(|error| error.to_string())?; let mut spawn_result = self.spawn_mcps(&spec.mcp_config_sources).await?; let snapshot = spawn_result @@ -290,7 +289,7 @@ impl AgentExecutor { } async fn spawn_mcps(&self, effective_mcp_config_sources: &[McpConfigSource]) -> Result { - let mut builder = mcp(&self.project_root).with_agent_deps(self.deps.clone()).with_builtin_servers(); + let mut builder = mcp(&self.project_root).with_builtin_servers(self.deps.clone()); if !effective_mcp_config_sources.is_empty() { builder = builder diff --git a/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs b/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs index e8a49aabc..e0b95abdf 100644 --- a/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs +++ b/crates/mcp-servers/tests/integration/plugins_mcp_agents.rs @@ -1,13 +1,19 @@ -use crate::common::{TestClient, TestResult}; +use crate::common::{TestClient, TestResult, test_error}; use aether_auth::FakeOAuthCredentialStore; +use aether_core::agent_spec::McpConfigSource; use aether_core::core::AgentDeps; +use aether_core::mcp::mcp; +use aether_core::mcp::run_mcp_task::{McpCommand, ToolExecutionEvent}; use aether_project::{AetherSettings, AetherSettingsSource, AgentCatalog, SettingsFileSource}; +use mcp_servers::McpBuilderExt; use mcp_servers::subagents::SubAgentsMcp; use mcp_servers::subagents::tools::{SpawnSubAgentsInput, SubAgentTask}; use std::fs; use std::path::Path; use std::sync::Arc; +use std::time::Duration; use tempfile::TempDir; +use tokio::sync::mpsc; fn spawn_input(tasks: &[(&str, &str)]) -> SpawnSubAgentsInput { SpawnSubAgentsInput { @@ -21,6 +27,82 @@ fn spawn_input(tasks: &[(&str, &str)]) -> SpawnSubAgentsInput { } } +const RUNTIME_PROVIDER_URL: &str = "http://127.0.0.1:1"; + +#[tokio::test] +async fn spawn_uses_runtime_registry_and_provider_overrides() -> TestResult { + let temp_dir = create_project_with_invocable_agent(); + let settings = AetherSettings::load( + temp_dir.path(), + [AetherSettingsSource::Json( + r#"{ + "agents": [{ + "name": "runtime-explorer", + "description": "Runtime supplied explorer", + "model": "anthropic:claude-sonnet-4-5", + "agentInvocable": true, + "prompts": [{"type":"text","text":"Use runtime settings."}] + }] +}"# + .to_string(), + )], + )?; + let mut provider = llm::ProviderConnectionOverride::url(RUNTIME_PROVIDER_URL); + provider.merge(llm::ProviderConnectionOverride::auth(llm::ProviderAuthMode::None)); + let overrides = + llm::ProviderConnectionOverrides::new(std::collections::BTreeMap::from([("anthropic".to_string(), provider)])); + let catalog = AgentCatalog::from_settings(temp_dir.path(), settings)?.with_provider_connections(overrides); + let deps = AgentDeps::default().with_agent_registry(catalog.registry().clone()); + let result = call_subagent_through_manager( + temp_dir.path(), + deps, + spawn_input(&[("runtime-explorer", "Do something"), ("coder", "Do something")]), + ) + .await?; + + assert!(result.contains(RUNTIME_PROVIDER_URL), "provider override missing from result: {result}"); + assert!(result.contains("Agent 'coder' not found"), "workspace-only agent should be rejected: {result}"); + Ok(()) +} + +#[tokio::test] +async fn embedded_subagents_use_runtime_catalog_instead_of_checkout_settings() { + let temp_dir = create_project_with_invocable_agent(); + let runtime_settings = AetherSettings::load( + temp_dir.path(), + [AetherSettingsSource::Json( + r#"{ + "agents": [ + { + "name": "runtime-explorer", + "description": "Runtime supplied explorer", + "model": "anthropic:claude-sonnet-4-5", + "agentInvocable": true, + "prompts": [{"type":"text","text":"Use runtime settings."}] + } + ] +}"# + .to_string(), + )], + ) + .expect("Failed to load runtime settings"); + let runtime_catalog = + AgentCatalog::from_settings(temp_dir.path(), runtime_settings).expect("Failed to create runtime catalog"); + + let instructions = subagent_instructions(temp_dir.path(), runtime_catalog).await; + + assert!(instructions.contains("runtime-explorer"), "runtime catalog agent missing: {instructions}"); + assert!(!instructions.contains("coder"), "workspace settings leaked into instructions: {instructions}"); +} + +#[tokio::test] +async fn embedded_subagents_report_no_agents_when_runtime_catalog_is_empty() { + let temp_dir = create_project_with_invocable_agent(); + let instructions = subagent_instructions(temp_dir.path(), AgentCatalog::empty(temp_dir.path().to_path_buf())).await; + assert!(instructions.contains("No sub-agents are currently available"), "unexpected instructions: {instructions}"); + assert!(!instructions.contains("coder"), "workspace settings leaked into instructions: {instructions}"); +} + #[tokio::test] async fn test_spawn_agent_with_coding_mcp_from_settings_catalog() { let test_files = vec![ @@ -51,8 +133,9 @@ async fn test_spawn_agent_with_coding_mcp_from_settings_catalog() { async fn test_spawn_subagent_codex_uses_oauth_store() -> TestResult { let temp_dir = create_project_with_codex_agent(); let mcp = TestClient::start(|| { - create_test_server(temp_dir.path()) - .with_agent_deps(AgentDeps::new(Arc::new(FakeOAuthCredentialStore::new()), None)) + let deps = AgentDeps::new(Arc::new(FakeOAuthCredentialStore::new()), None) + .with_agent_registry(test_registry(temp_dir.path())); + SubAgentsMcp::embedded(temp_dir.path().to_path_buf(), deps) }) .await?; @@ -130,6 +213,47 @@ async fn test_spawn_subagents_task_id_assignment() -> TestResult { Ok(()) } +async fn call_subagent_through_manager( + project_root: &Path, + deps: AgentDeps, + input: SpawnSubAgentsInput, +) -> TestResult { + let mut spawn = mcp(project_root) + .with_builtin_servers(deps) + .from_mcp_config_sources(&[McpConfigSource::Json( + r#"{"servers":{"subagents":{"type":"in-memory","args":[]}}}"#.to_string(), + )]) + .await? + .spawn() + .await?; + + let snapshot = spawn.block_until_ready().await.ok_or_else(|| test_error("MCP bootstrap aborted"))?; + let tool = snapshot + .tool_definitions + .iter() + .find(|tool| tool.name.ends_with("spawn_subagent")) + .ok_or_else(|| test_error("spawn_subagent tool missing"))?; + let request = llm::ToolCallRequest { + id: "runtime-registry-test".to_string(), + name: tool.name.clone(), + arguments: serde_json::to_string(&input)?, + }; + + let (event_tx, mut event_rx) = mpsc::channel(4); + spawn + .command_tx + .send(McpCommand::ExecuteTool { request, trace_context: None, timeout: Duration::MAX, tx: event_tx }) + .await?; + + while let Some(event) = event_rx.recv().await { + if let ToolExecutionEvent::Complete { result, .. } = event { + let result = result.map_err(|error| test_error(error.error))?; + return Ok(result.result); + } + } + Err(test_error("MCP manager stopped before returning the tool result").into()) +} + fn create_test_files(files: &[(&str, &str)]) -> TempDir { let temp_dir = TempDir::new().expect("Failed to create temp directory"); for (path, content) in files { @@ -180,16 +304,36 @@ fn create_project_with_codex_agent() -> TempDir { )]) } -fn create_test_server(test_dir: &Path) -> SubAgentsMcp { +/// Bring up the embedded subagents server behind an MCP manager and return the +/// instructions it advertises, which name the agents it will accept. +async fn subagent_instructions(project_root: &Path, catalog: AgentCatalog) -> String { + let deps = AgentDeps::default().with_agent_registry(catalog.registry().clone()); + let mut spawn = mcp(project_root) + .with_builtin_servers(deps) + .from_mcp_config_sources(&[McpConfigSource::Json( + r#"{"servers":{"subagents":{"type":"in-memory","args":[]}}}"#.to_string(), + )]) + .await + .expect("Failed to configure subagents MCP") + .spawn() + .await + .expect("Failed to spawn MCP manager"); + + let snapshot = spawn.block_until_ready().await.expect("MCP bootstrap aborted"); + snapshot.instructions.get("subagents").expect("Missing subagents instructions").clone() +} + +/// The catalog a standalone server would discover from `test_dir`. +fn test_registry(test_dir: &Path) -> aether_core::core::AgentRegistry { let settings = AetherSettings::load( test_dir, [AetherSettingsSource::OptionalFile(SettingsFileSource::new(".aether/settings.json", test_dir))], ) .expect("Failed to load project settings"); - let catalog = if settings.agents.is_empty() { - AgentCatalog::empty(test_dir.to_path_buf()) - } else { - AgentCatalog::from_settings(test_dir, settings).expect("Failed to create agent catalog") - }; - SubAgentsMcp::new(catalog, test_dir.to_path_buf()) + AgentCatalog::from_settings_or_empty(test_dir, settings).expect("Failed to create agent catalog").registry().clone() +} + +fn create_test_server(test_dir: &Path) -> SubAgentsMcp { + let deps = AgentDeps::default().with_agent_registry(test_registry(test_dir)); + SubAgentsMcp::embedded(test_dir.to_path_buf(), deps) }