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
8 changes: 3 additions & 5 deletions crates/loopal-agent-server/src/agent_setup.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::agent_setup_context::AgentSetupContext;
use crate::agent_setup_helpers::{
build_fork_synthetic_turn, build_microcompact_idle, build_model_router, collect_feature_tags,
build_fork_synthetic_turn, build_microcompact_idle, collect_feature_tags,
spawn_sub_agent_forwarder,
};
use crate::params::AgentSetupResult;
Expand All @@ -24,11 +24,9 @@ pub async fn build_with_frontend(ctx: AgentSetupContext<'_>) -> anyhow::Result<A
decision_context,
decision_cell,
session_id,
router,
} = ctx;
let router = build_model_router(&config.settings);
let model = router
.resolve(loopal_provider_api::TaskType::Default)
.to_string();
let model = router.model();
let permission_mode = config.settings.permission_mode;
let thinking_config = config.settings.thinking.clone();
let (mode, mode_str) = match start.mode.as_deref() {
Expand Down
4 changes: 4 additions & 0 deletions crates/loopal-agent-server/src/agent_setup_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use std::sync::Arc;
use loopal_config::ResolvedConfig;
use loopal_kernel::Kernel;
use loopal_protocol::InterruptSignal;
use loopal_provider_api::SharedModelRouter;
use loopal_runtime::frontend::traits::AgentFrontend;

use crate::params::StartParams;
Expand Down Expand Up @@ -39,6 +40,7 @@ pub struct AgentSetupContext<'a> {
pub decision_context: loopal_runtime::frontend::DecisionContext,
pub decision_cell: loopal_runtime::frontend::DecisionCell,
pub session_id: &'a str,
pub router: SharedModelRouter,
}

impl<'a> AgentSetupContext<'a> {
Expand All @@ -57,6 +59,7 @@ impl<'a> AgentSetupContext<'a> {
decision_context: loopal_runtime::frontend::DecisionContext,
decision_cell: loopal_runtime::frontend::DecisionCell,
session_id: &'a str,
router: SharedModelRouter,
) -> Self {
Self {
cwd,
Expand All @@ -72,6 +75,7 @@ impl<'a> AgentSetupContext<'a> {
decision_context,
decision_cell,
session_id,
router,
}
}
}
13 changes: 5 additions & 8 deletions crates/loopal-agent-server/src/agent_setup_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,17 @@ use std::time::Duration;

use loopal_config::{CompactionSettings, ResolvedConfig, Settings};
use loopal_protocol::{AgentEvent, AgentEventPayload};
use loopal_provider_api::{ContentBlock, Message, MessageRole, ModelRouter, TaskType};
use loopal_provider_api::{ContentBlock, Message, MessageRole, ModelRouter};
use loopal_runtime::frontend::traits::AgentFrontend;
use loopal_turn::{InjectionKind, Turn, TurnOutcome, TurnStep, TurnTrigger};

use crate::params::StartParams;

const DEFAULT_SUMMARIZATION_MODEL: &str = "claude-haiku-4-5-20251001";

/// No hardcoded per-task default: unconfigured tasks fall back to the main
/// model via `ModelRouter::resolve` — a pinned default could reference an
/// unconfigured provider and silently break compaction.
pub fn build_model_router(settings: &Settings) -> ModelRouter {
let mut routing = settings.model_routing.clone();
routing
.entry(TaskType::Summarization)
.or_insert_with(|| DEFAULT_SUMMARIZATION_MODEL.to_string());
ModelRouter::from_parts(settings.model.clone(), routing)
ModelRouter::from_parts(settings.model.clone(), settings.model_routing.clone())
}

/// Resolve the microcompact idle duration. Logs a hint when the user has
Expand Down
24 changes: 24 additions & 0 deletions crates/loopal-agent-server/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub struct AgentSetupResult {
pub agent_shared: Arc<AgentShared>,
}

#[derive(Default)]
pub struct StartParams {
#[allow(dead_code)]
pub cwd: Option<String>,
Expand Down Expand Up @@ -97,6 +98,7 @@ pub async fn build_kernel_from_config(
)
.await;
let kernel = Arc::new(kernel);
log_unresolvable_routing(&kernel);

if production {
spawn_proxy_mcp_settle_poll(Arc::downgrade(&kernel));
Expand All @@ -105,6 +107,28 @@ pub async fn build_kernel_from_config(
Ok(kernel)
}

/// Logging policy for unresolvable routes: the unreachable main model is an
/// error (LLM calls will fail), a bad `model_routing` entry is a warning (it
/// falls back to the main model). Soft by design — never aborts startup, since
/// sub-agent/proxy and test paths legitimately register fewer providers.
fn log_unresolvable_routing(kernel: &Kernel) {
for entry in kernel.unresolvable_models() {
match entry.slot {
loopal_kernel::RoutedSlot::MainModel => tracing::error!(
model = %entry.model,
"configured main model resolves to no registered provider; \
LLM calls will fail — configure its provider or set `model`"
),
loopal_kernel::RoutedSlot::Task(task) => tracing::warn!(
model = %entry.model,
?task,
"model_routing entry resolves to no registered provider; \
that task will fail when invoked"
),
}
}
}

pub fn build_kernel_with_provider(
provider: Arc<dyn loopal_provider_api::Provider>,
) -> anyhow::Result<Arc<Kernel>> {
Expand Down
13 changes: 4 additions & 9 deletions crates/loopal-agent-server/src/session_handlers_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::sync::Arc;
use loopal_config::ResolvedConfig;
use loopal_decision_api::DecisionMode;
use loopal_kernel::Kernel;
use loopal_provider_api::{ModelRouter, Provider, ProviderResolver, TaskType};
use loopal_provider_api::{ModelRouterReader, Provider, ProviderResolver, TaskType};
use loopal_runtime::frontend::permission_handler::PermissionHandler;
use loopal_runtime::frontend::question_handler::QuestionHandler;
use loopal_runtime::frontend::traits::EventEmitter;
Expand All @@ -16,17 +16,15 @@ use crate::ipc_handlers::{IpcPermissionHandler, IpcQuestionHandler, SessionRef};

struct KernelProviderResolver {
kernel: Arc<Kernel>,
router: ModelRouter,
router: ModelRouterReader,
}

impl ProviderResolver for KernelProviderResolver {
fn resolve_for(
&self,
task: TaskType,
) -> Result<(String, Arc<dyn Provider>), loopal_error::LoopalError> {
let model = self.router.resolve(task).to_string();
let provider = self.kernel.resolve_provider(&model)?;
Ok((model, provider))
self.kernel.resolve_task(&self.router.read(), task)
}
}

Expand All @@ -35,6 +33,7 @@ pub fn build_session_handlers(
kernel: &Arc<Kernel>,
session: SessionRef,
context: DecisionContext,
router: ModelRouterReader,
) -> (
Box<dyn PermissionHandler>,
Box<dyn QuestionHandler>,
Expand All @@ -61,10 +60,6 @@ pub fn build_session_handlers(
))
.with_question_system_prompt(config.classifier_prompt.clone()),
);
let router = ModelRouter::from_parts(
config.settings.model.clone(),
config.settings.model_routing.clone(),
);
let resolver: Arc<dyn ProviderResolver> = Arc::new(KernelProviderResolver {
kernel: kernel.clone(),
router,
Expand Down
5 changes: 5 additions & 0 deletions crates/loopal-agent-server/src/session_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ use loopal_protocol::InterruptSignal;
use loopal_runtime::agent_input::AgentInput;

use crate::agent_setup;
use crate::agent_setup_helpers::build_model_router;
use crate::hub_frontend::HubFrontend;
use crate::session_handlers_factory::build_session_handlers;
use crate::session_hub::{SessionHub, SharedSession};
use crate::session_spawn::{parse_start_params, spawn_agent_and_bridges};
use crate::session_start_prompt::push_prompt_envelope;
use loopal_provider_api::SharedModelRouter;

pub(crate) struct SessionHandle {
pub session_id: String,
Expand Down Expand Up @@ -94,11 +96,13 @@ pub(crate) async fn start_session(
));
let decision_context =
loopal_runtime::frontend::DecisionContext::with_cwd(cwd.to_string_lossy().into_owned());
let model_router = SharedModelRouter::new(build_model_router(&config.settings));
let (perm_handler, q_handler, decision_cell) = build_session_handlers(
&config,
&kernel,
session_holder.clone(),
decision_context.clone(),
model_router.reader(),
);
let frontend_placeholder = Arc::new(HubFrontend::new(
session_holder,
Expand Down Expand Up @@ -126,6 +130,7 @@ pub(crate) async fn start_session(
decision_context,
decision_cell,
&preset_session_id,
model_router,
))
.await?;
let agent_params = setup.params;
Expand Down
30 changes: 9 additions & 21 deletions crates/loopal-agent-server/tests/suite/hub_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use std::sync::Arc;
use std::time::Duration;

use tokio::sync::{Mutex, mpsc, watch};
use tokio::sync::{mpsc, watch};

use loopal_error::AgentOutput;
use loopal_ipc::StdioTransport;
Expand All @@ -19,7 +19,7 @@ use loopal_test_support::mock_provider::MultiCallProvider;
use loopal_test_support::scenarios::Calls;

use loopal_agent_server::testing::{
AgentInput, SharedSession, StartParams, build_kernel_with_provider,
AgentInput, SharedSession, StartParams, build_kernel_with_provider, build_model_router,
};

pub const T: Duration = Duration::from_secs(10);
Expand Down Expand Up @@ -108,14 +108,11 @@ pub async fn build_hub_harness_with(
let (watch_tx, watch_rx) = watch::channel(0u64);
let interrupt_tx = Arc::new(watch_tx);

let session = Arc::new(SharedSession {
session_id: "hub-test".into(),
clients: Mutex::new(Vec::new()),
input_tx: input_tx.clone(),
interrupt: interrupt.clone(),
interrupt_tx: interrupt_tx.clone(),
agent_shared: Mutex::new(None),
});
let session = Arc::new(SharedSession::placeholder(
input_tx.clone(),
interrupt.clone(),
interrupt_tx.clone(),
));
let (server_conn, client_conn, client_rx) = conn_pair();
session.add_client("test".into(), server_conn).await;

Expand All @@ -125,18 +122,8 @@ pub async fn build_hub_harness_with(
config.settings.permission_mode = pm;
}
let start = StartParams {
cwd: None,
model: None,
mode: None,
prompt: None,
permission_mode: None,
decision_mode: None,
no_sandbox: true,
resume: None,
lifecycle: loopal_runtime::LifecycleMode::Persistent,
agent_type: None,
depth: None,
fork_context: None,
..Default::default()
};
let (hub_conn, _hub_peer) = loopal_ipc::duplex_pair();
let (hub_connection, _hub_rx) = loopal_ipc::Connection::new(hub_conn).into_listening();
Expand All @@ -157,6 +144,7 @@ pub async fn build_hub_harness_with(
loopal_runtime::frontend::DecisionContext::with_cwd("/tmp/test"),
loopal_runtime::frontend::DecisionCell::new(loopal_decision_api::DecisionMode::Manual),
"harness-session",
loopal_provider_api::SharedModelRouter::new(build_model_router(&config.settings)),
),
)
.await
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
use indexmap::IndexMap;
use std::sync::Arc;

use loopal_agent_server::testing::{SessionRef, SharedSession, build_session_handlers};
use loopal_agent_server::testing::{
SessionRef, SharedSession, build_model_router, build_session_handlers,
};
use loopal_config::{ResolvedConfig, Settings};
use loopal_decision_api::DecisionMode;
use loopal_kernel::Kernel;
use loopal_runtime::frontend::DecisionContext;
use loopal_provider_api::SharedModelRouter;
use loopal_runtime::frontend::permission_handler::PermissionHandler;
use loopal_runtime::frontend::question_handler::QuestionHandler;
use loopal_runtime::frontend::{DecisionCell, DecisionContext};

type Handlers = (
Box<dyn PermissionHandler>,
Box<dyn QuestionHandler>,
DecisionCell,
);

fn handlers(config: &ResolvedConfig, kernel: &Arc<Kernel>, session: SessionRef) -> Handlers {
let router = SharedModelRouter::new(build_model_router(&config.settings)).reader();
build_session_handlers(
config,
kernel,
session,
DecisionContext::with_cwd("/tmp/test"),
router,
)
}

fn empty_config(decision: DecisionMode) -> ResolvedConfig {
let settings = Settings {
Expand Down Expand Up @@ -39,12 +61,7 @@ async fn manual_decision_yields_ipc_only_no_primary_connection() {
let config = empty_config(DecisionMode::Manual);
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
let session = dummy_session();
let (perm, _q, _cell) = build_session_handlers(
&config,
&kernel,
session,
DecisionContext::with_cwd("/tmp/test"),
);
let (perm, _q, _cell) = handlers(&config, &kernel, session);
let outcome = perm.decide("id1", "Bash", &serde_json::json!({})).await;
assert_eq!(
outcome.decision,
Expand All @@ -63,12 +80,7 @@ async fn auto_decision_wraps_with_auto_handlers_and_falls_back() {
let config = empty_config(DecisionMode::Classifier);
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
let session = dummy_session();
let (perm, _q, _cell) = build_session_handlers(
&config,
&kernel,
session,
DecisionContext::with_cwd("/tmp/test"),
);
let (perm, _q, _cell) = handlers(&config, &kernel, session);
let outcome = perm.decide("id1", "Bash", &serde_json::json!({})).await;
assert_eq!(
outcome.decision,
Expand All @@ -92,12 +104,7 @@ async fn manual_question_path_cancels_without_connection() {
let config = empty_config(DecisionMode::Manual);
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
let session = dummy_session();
let (_perm, q, _cell) = build_session_handlers(
&config,
&kernel,
session,
DecisionContext::with_cwd("/tmp/test"),
);
let (_perm, q, _cell) = handlers(&config, &kernel, session);
let outcome = q.ask(vec![]).await;
assert!(
matches!(
Expand All @@ -118,12 +125,7 @@ async fn auto_question_path_chains_fallback_when_provider_unresolvable() {
let config = empty_config(DecisionMode::Classifier);
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
let session = dummy_session();
let (_perm, q, _cell) = build_session_handlers(
&config,
&kernel,
session,
DecisionContext::with_cwd("/tmp/test"),
);
let (_perm, q, _cell) = handlers(&config, &kernel, session);
let outcome = q.ask(vec![]).await;
assert!(
matches!(
Expand All @@ -144,12 +146,7 @@ async fn decision_cell_switch_flips_manual_to_classifier_at_runtime() {
let config = empty_config(DecisionMode::Manual);
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
let session = dummy_session();
let (perm, _q, cell) = build_session_handlers(
&config,
&kernel,
session,
DecisionContext::with_cwd("/tmp/test"),
);
let (perm, _q, cell) = handlers(&config, &kernel, session);
let manual = perm.decide("id1", "Bash", &serde_json::json!({})).await;
assert!(
manual.reason.contains("no primary connection"),
Expand All @@ -172,12 +169,7 @@ async fn agent_decision_falls_back_to_classifier_path_today() {
let config = empty_config(DecisionMode::Agent);
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
let session = dummy_session();
let (perm, q, _cell) = build_session_handlers(
&config,
&kernel,
session,
DecisionContext::with_cwd("/tmp/test"),
);
let (perm, q, _cell) = handlers(&config, &kernel, session);
// Permission path: Agent → Classifier → IpcPermission fallback denies (no conn)
let outcome = perm.decide("id1", "Bash", &serde_json::json!({})).await;
assert_eq!(
Expand Down
Loading
Loading