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
3 changes: 2 additions & 1 deletion crates/aether-cli/src/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down
6 changes: 3 additions & 3 deletions crates/aether-cli/src/acp/session_actor.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -115,7 +115,7 @@ pub(crate) struct SessionActorInit {
pub repository: Arc<SessionStore>,
pub oauth_credential_store: Arc<dyn OAuthCredentialStorage>,
pub active_agent: AgentKey,
pub specs: HashMap<AgentKey, AgentSpec>,
pub specs: SessionAgents,
pub runtime_factory: Arc<dyn RuntimeFactory>,
pub transcript: Vec<SessionEvent>,
pub modes: Modes,
Expand All @@ -126,7 +126,7 @@ pub(crate) struct SessionActorInit {
/// is serialized through the command channel.
pub(crate) struct SessionActor {
active_agent: AgentKey,
specs: HashMap<AgentKey, AgentSpec>,
specs: SessionAgents,
runtimes: HashMap<AgentKey, AgentRuntime>,
runtime_factory: Arc<dyn RuntimeFactory>,
runtime_event_tx: mpsc::Sender<RuntimeEvent>,
Expand Down
31 changes: 31 additions & 0 deletions crates/aether-cli/src/acp/session_agents.rs
Original file line number Diff line number Diff line change
@@ -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<AgentSpec>,
}

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),
}
}
}
170 changes: 82 additions & 88 deletions crates/aether-cli/src/acp/session_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<ReasoningEffort>,
},
}

impl InitialSessionSelection {
pub fn agent(name: String) -> Self {
Self::Agent(name)
}

pub fn model(model: String, reasoning_effort: Option<ReasoningEffort>) -> 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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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<acp::McpServer>) -> Arc<dyn RuntimeFactory> {
let deps = AgentDeps::new(Arc::clone(&self.oauth_credential_store), self.observer_factory.clone());
fn production_runtime_factory(
&self,
cwd: PathBuf,
mcp_servers: Vec<acp::McpServer>,
catalog: &AgentCatalog,
) -> Arc<dyn RuntimeFactory> {
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))
}

Expand Down Expand Up @@ -184,84 +171,60 @@ impl SessionFactory {
mode_catalog: &mut SessionModeCatalog,
default_model: &LlmModel,
) -> Result<ResolvedSession, acp::Error> {
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<ResolvedSession, acp::Error> {
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<ReasoningEffort>,
) -> 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<SessionModeCatalog, acp::Error> {
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<AgentSpec> = 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<AgentKey, AgentSpec>,
specs: SessionAgents,
modes: Modes,
available: Vec<LlmModel>,
}
Expand All @@ -271,8 +234,39 @@ struct ResolvedSession {
config: SessionConfigState,
}

fn resolve_loaded_session(
mode_catalog: &mut SessionModeCatalog,
meta: &SessionMeta,
events: &[SessionEvent],
) -> Result<ResolvedSession, acp::Error> {
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<ReasoningEffort>,
) -> 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<ResolvedSession, acp::Error> {
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()
})?;
Expand Down
3 changes: 2 additions & 1 deletion crates/aether-cli/src/acp/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
Loading