Improve orchestration agent model selection

This commit is contained in:
2026-08-26 02:15:52 -05:00
parent 92ab03be07
commit 2022716f91
18 changed files with 486 additions and 60 deletions
+1
View File
@@ -166,6 +166,7 @@ fn convert_run_agents(
name: config.name,
prompt: config.prompt,
title: config.title,
model_id: String::new(),
})
.collect(),
plan_id,
@@ -1247,7 +1247,7 @@ pub fn compose_run_agents_child_prompt(base_prompt: &str, per_agent_prompt: &str
}
}
/// Translates run-wide config into a per-child
/// Translates shared config and the child's model selection into a per-child
/// [`StartAgentExecutionMode`]. Returns `Err` for rejected
/// combinations (e.g. OpenCode+Remote).
///
@@ -1262,12 +1262,15 @@ pub fn run_agents_to_start_agent_mode(
run_auth_secret_name: Option<&str>,
cfg: &RunAgentsAgentRunConfig,
) -> Result<StartAgentExecutionMode, String> {
let child_model_id = if cfg.model_id.trim().is_empty() {
run_model_id.trim()
} else {
cfg.model_id.trim()
};
match run_execution_mode {
RunAgentsExecutionMode::Local => {
let trimmed = run_harness_type.trim();
// Propagate run-wide model selection for local launches.
let trimmed_model_id = run_model_id.trim();
let model_id = (!trimmed_model_id.is_empty()).then(|| trimmed_model_id.to_string());
let model_id = (!child_model_id.is_empty()).then(|| child_model_id.to_string());
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("oz") {
Ok(StartAgentExecutionMode::Local {
harness_type: None,
@@ -1299,7 +1302,7 @@ pub fn run_agents_to_start_agent_mode(
Ok(StartAgentExecutionMode::Remote {
environment_id: environment_id.clone(),
skill_references: run_skills.to_vec(),
model_id: run_model_id.to_string(),
model_id: child_model_id.to_string(),
computer_use_enabled: *computer_use_enabled,
worker_host: worker_host.clone(),
harness_type: run_harness_type.to_string(),
@@ -266,6 +266,7 @@ fn execute_denies_mixed_batch_containing_launched_agent() {
name: "new-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
model_id: String::new(),
});
let execution = state.executor.update(&mut app, |executor, ctx| {
@@ -306,6 +307,7 @@ fn validate_request_rejects_blank_and_duplicate_agent_names() {
name: " child ".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
model_id: String::new(),
});
assert_eq!(
validate_request(&request),
@@ -323,6 +325,7 @@ fn validate_request_allows_unique_sibling_names() {
name: "second-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
model_id: String::new(),
});
assert_eq!(validate_request(&request), Ok(()));
@@ -384,6 +387,7 @@ fn recovered_run_agents_reattaches_existing_child_and_launches_only_missing_chil
name: "missing-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
model_id: String::new(),
});
state.executor.update(&mut app, |executor, _| {
executor
@@ -454,12 +458,12 @@ fn recovered_run_agents_reattaches_existing_child_and_launches_only_missing_chil
assert_eq!(agents.len(), 2);
assert!(matches!(
&agents[0].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
RunAgentsAgentOutcomeKind::Completed { agent_id, .. }
if agent_id == &existing_child_id.to_string()
));
assert!(matches!(
&agents[1].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
RunAgentsAgentOutcomeKind::Completed { agent_id, .. }
if agent_id == &missing_child_id.to_string()
));
});
@@ -766,6 +770,7 @@ fn remote_run_agents_action(harness_type: &str) -> AIAgentAction {
name: "child".to_string(),
prompt: "Help".to_string(),
title: String::new(),
model_id: String::new(),
}],
plan_id: String::new(),
harness_auth_secret_name: None,
@@ -789,6 +794,7 @@ fn local_codex_run_agents_maps_to_local_harness_mode_when_flag_enabled() {
name: "child".to_string(),
prompt: "Investigate the failure".to_string(),
title: String::new(),
model_id: String::new(),
};
let mode = run_agents_to_start_agent_mode(
@@ -810,6 +816,34 @@ fn local_codex_run_agents_maps_to_local_harness_mode_when_flag_enabled() {
);
}
#[test]
fn child_model_overrides_the_run_wide_model() {
let cfg = RunAgentsAgentRunConfig {
name: "child".to_string(),
prompt: "Investigate".to_string(),
title: String::new(),
model_id: "child-model".to_string(),
};
let mode = run_agents_to_start_agent_mode(
&RunAgentsExecutionMode::Local,
"oz",
"fallback-model",
&[],
None,
&cfg,
)
.unwrap();
assert_eq!(
mode,
StartAgentExecutionMode::Local {
harness_type: None,
model_id: Some("child-model".to_string()),
}
);
}
fn persist_default_auth_secret(app: &mut App, harness_config_name: &str, secret_name: &str) {
CloudAgentSettings::handle(app).update(app, |settings, ctx| {
let mut secrets = settings.last_selected_auth_secret.value().clone();
+1
View File
@@ -483,6 +483,7 @@ fn agent_cfg() -> RunAgentsAgentRunConfig {
name: "child".to_string(),
prompt: "do X".to_string(),
title: "Child".to_string(),
model_id: String::new(),
}
}
+24 -6
View File
@@ -81,10 +81,11 @@ use crate::ai::provider::ProviderConfig;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
use crate::ai::runtime::{
prepare_provider_run, provider_runtime_for_request, PreparedProviderRun, ProviderActionContext,
ProviderRunBlock, ProviderRunCoordinator, ProviderRunProfile, ProviderRunProjection,
ProviderRunResponseProjector, ProviderToolExecutionRef, ProviderToolLifecycleOutcome,
RuntimeResponseConfig, BASE_PROVIDER_PROFILE, CLI_MONITOR_PROVIDER_PROFILE,
prepare_provider_run, provider_runtime_for_request, OrchestrationModelOption,
PreparedProviderRun, ProviderActionContext, ProviderRunBlock, ProviderRunCoordinator,
ProviderRunProfile, ProviderRunProjection, ProviderRunResponseProjector,
ProviderToolExecutionRef, ProviderToolLifecycleOutcome, RuntimeResponseConfig,
BASE_PROVIDER_PROFILE, CLI_MONITOR_PROVIDER_PROFILE,
};
use crate::ai::AIRequestUsageModel;
use crate::cloud_object::model::persistence::CloudModel;
@@ -6296,10 +6297,27 @@ impl BlocklistAIController {
request_params: api::RequestParams,
ctx: &mut ModelContext<Self>,
) {
let orchestration_models = LLMPreferences::as_ref(ctx)
.get_base_llm_choices_for_agent_mode(ctx)
.filter(|model| model.disable_reason.is_none())
.map(|model| OrchestrationModelOption {
id: model.id.to_string(),
display_name: model.display_name.clone(),
provider: model.provider.display_name().to_string(),
quality: model.spec.as_ref().map(|spec| spec.quality),
cost: model.spec.as_ref().map(|spec| spec.cost),
credit_multiplier: model.usage_metadata.credit_multiplier,
})
.collect();
let _ = ctx.spawn(
async move {
prepare_provider_run(base_provider_config, cli_provider_config, request_params)
.await
prepare_provider_run(
base_provider_config,
cli_provider_config,
request_params,
orchestration_models,
)
.await
},
move |me, result, ctx| {
me.handle_prepared_provider_run(conversation_id, stream_id, result, ctx);
@@ -430,6 +430,30 @@ pub fn populate_model_picker_for_harness<A: OrchestrationControlAction, V: View>
harness_type: &str,
is_local: bool,
ctx: &mut ViewContext<V>,
) {
populate_model_picker_for_harness_with_action(
dropdown,
initial_model_id,
harness_type,
is_local,
A::model_changed,
ctx,
);
}
/// Populates an orchestration model picker while allowing the caller to
/// attach extra identity (for example, a child-agent name) to model actions.
pub fn populate_model_picker_for_harness_with_action<
A: OrchestrationControlAction,
V: View,
F: Fn(String) -> A + Clone,
>(
dropdown: &ViewHandle<FilterableDropdown<A>>,
initial_model_id: &str,
harness_type: &str,
is_local: bool,
model_changed: F,
ctx: &mut ViewContext<V>,
) {
let initial_model_id = initial_model_id.to_string();
let harness_type = harness_type.to_string();
@@ -459,9 +483,7 @@ pub fn populate_model_picker_for_harness<A: OrchestrationControlAction, V: View>
let items = available_model_menu_items(
ordered_choices,
move |llm| {
DropdownAction::select_action_and_close(A::model_changed(
llm.id.to_string(),
))
DropdownAction::select_action_and_close(model_changed(llm.id.to_string()))
},
None,
None,
@@ -476,21 +498,22 @@ pub fn populate_model_picker_for_harness<A: OrchestrationControlAction, V: View>
}
Some(Harness::Codex) if is_local => {
// Local Codex: only "Default model" entry.
let items = vec![default_model_menu_item::<A>()];
let items = vec![default_model_menu_item(model_changed)];
dropdown.set_rich_items(items, ctx_dropdown);
dropdown.set_selected_by_name(DEFAULT_MODEL_LABEL, ctx_dropdown);
}
Some(harness) => {
// Non-Oz harness: "Default model" at top, then server-provided
// harness models.
let mut items: Vec<MenuItem<DropdownAction>> = vec![default_model_menu_item::<A>()];
let mut items: Vec<MenuItem<DropdownAction>> =
vec![default_model_menu_item(model_changed.clone())];
let availability = HarnessAvailabilityModel::as_ref(ctx_dropdown);
if let Some(models) = availability.models_for(harness) {
for model in models {
let model_id = model.id.clone();
let fields = MenuItemFields::new(&model.display_name)
.with_on_select_action(DropdownAction::select_action_and_close(
A::model_changed(model_id),
model_changed(model_id),
));
items.push(MenuItem::Item(fields));
}
@@ -519,10 +542,12 @@ pub fn populate_model_picker_for_harness<A: OrchestrationControlAction, V: View>
}
/// Creates a "Default model" menu item that emits an empty model_id.
fn default_model_menu_item<A: OrchestrationControlAction>() -> MenuItem<DropdownAction> {
fn default_model_menu_item<A: OrchestrationControlAction>(
model_changed: impl Fn(String) -> A,
) -> MenuItem<DropdownAction> {
MenuItem::Item(
MenuItemFields::new(DEFAULT_MODEL_LABEL).with_on_select_action(
DropdownAction::select_action_and_close(A::model_changed(String::new())),
DropdownAction::select_action_and_close(model_changed(String::new())),
),
)
}
@@ -598,6 +623,7 @@ pub fn populate_harness_picker<A: OrchestrationControlAction, V: View>(
let harnesses = availability.available_harnesses();
let resolve_entry_harness = |harness: Harness, display_name: &str| match harness {
Harness::Unknown if display_name == "Warp" => Harness::Oz,
Harness::Unknown => [
Harness::Oz,
Harness::Claude,
@@ -644,11 +670,15 @@ pub fn populate_harness_picker<A: OrchestrationControlAction, V: View>(
} else {
None
};
// Use the server-provided display_name for the label so stale
// cache entries (where harness deserializes as Unknown) still
// show the correct name.
let mut fields = MenuItemFields::new(&entry.display_name)
.with_icon(harness_display::icon_for(harness));
// Oz is branded locally so stale server/cache values cannot
// reintroduce the legacy product name.
let display_name = if harness == Harness::Oz {
harness_display::display_name(harness)
} else {
&entry.display_name
};
let mut fields =
MenuItemFields::new(display_name).with_icon(harness_display::icon_for(harness));
if let Some(color) = harness_display::brand_color(harness) {
fields = fields.with_override_icon_color(Fill::from(color));
}
@@ -673,10 +703,10 @@ pub fn populate_harness_picker<A: OrchestrationControlAction, V: View>(
// is Unknown but entry.display_name is still correct.
if selected_name.is_none() {
if harness_str.eq_ignore_ascii_case(&initial_harness) {
selected_name = Some(entry.display_name.clone());
selected_name = Some(display_name.to_string());
} else if let Some(target_display) = target_display {
if entry.display_name == target_display {
selected_name = Some(entry.display_name.clone());
if display_name == target_display {
selected_name = Some(display_name.to_string());
}
}
}
@@ -13,9 +13,9 @@ use ai::skills::SkillReference;
use galaxy_core::send_telemetry_from_ctx;
use pathfinder_geometry::vector::vec2f;
use warpui::elements::{
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
Stack, Text, Wrap,
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Flex, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Stack, Text, Wrap,
};
use warpui::keymap::FixedBinding;
use warpui::{
@@ -128,14 +128,16 @@ impl RunAgentsEditState {
if matches!(req.execution_mode, RunAgentsExecutionMode::Local) {
orch.sanitize_for_local_execution();
}
Self {
let mut state = Self {
orch,
agent_run_configs: req.agent_run_configs.clone(),
base_prompt: req.base_prompt.clone(),
summary: req.summary.clone(),
skills: req.skills.clone(),
plan_id: req.plan_id.clone(),
}
};
state.fill_missing_agent_models();
state
}
pub fn to_request(&self) -> RunAgentsRequest {
@@ -151,6 +153,14 @@ impl RunAgentsEditState {
harness_auth_secret_name: self.orch.auth_secret_name().map(str::to_string),
}
}
fn fill_missing_agent_models(&mut self) {
for config in &mut self.agent_run_configs {
if config.model_id.trim().is_empty() {
config.model_id = self.orch.model_id.clone();
}
}
}
}
impl OrchestrationControlAction for RunAgentsCardViewAction {
@@ -197,6 +207,10 @@ pub enum RunAgentsCardViewAction {
ModelChanged {
model_id: String,
},
AgentModelChanged {
agent_name: String,
model_id: String,
},
HarnessChanged {
harness_type: String,
},
@@ -225,6 +239,8 @@ struct RunAgentsChildState {
conversation_id: Option<AIConversationId>,
removed: bool,
mouse_state: MouseStateHandle,
model_picker:
Option<ViewHandle<crate::view_components::FilterableDropdown<RunAgentsCardViewAction>>>,
}
impl RunAgentsChildState {
@@ -234,6 +250,7 @@ impl RunAgentsChildState {
conversation_id: None,
removed: false,
mouse_state: MouseStateHandle::default(),
model_picker: None,
}
}
}
@@ -401,6 +418,7 @@ fn resolve_interactive_defaults(
}
}
}
state.fill_missing_agent_models();
}
impl RunAgentsCardView {
pub fn new(
@@ -537,6 +555,8 @@ impl RunAgentsCardView {
// to the full confirmation UI.
resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.reconcile_agent_model_selections(ctx);
me.repopulate_agent_model_pickers(ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
if let Some(conversation_id) = me.block_model.conversation_id(ctx) {
@@ -548,7 +568,7 @@ impl RunAgentsCardView {
}
});
// Repopulate the model picker when available Warp LLMs change.
// Repopulate the model pickers when available Galaxy LLMs change.
// Only relevant for Oz harness — non-Oz harnesses get their
// model catalog from HarnessAvailabilityModel, not LLMPreferences.
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
@@ -563,6 +583,8 @@ impl RunAgentsCardView {
ctx,
);
}
me.reconcile_agent_model_selections(ctx);
me.repopulate_agent_model_pickers(ctx);
}
});
@@ -582,6 +604,8 @@ impl RunAgentsCardView {
ctx,
);
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.reconcile_agent_model_selections(ctx);
me.repopulate_agent_model_pickers(ctx);
me.refresh_accept_button_state(ctx);
ctx.notify();
}
@@ -593,6 +617,8 @@ impl RunAgentsCardView {
// Deleted events also force a repopulate so this card
// stops surfacing the deleted secret as an option.
oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx);
me.reconcile_agent_model_selections(ctx);
me.repopulate_agent_model_pickers(ctx);
me.refresh_accept_button_state(ctx);
me.maybe_auto_open_create_modal(ctx);
ctx.notify();
@@ -651,6 +677,7 @@ impl RunAgentsCardView {
};
view.ensure_pickers(ctx);
view.ensure_agent_model_pickers(ctx);
view.refresh_accept_button_state(ctx);
// No-ops if secrets are still in flight; the `AuthSecretsLoaded`
// subscription will retry once they resolve.
@@ -683,6 +710,7 @@ impl RunAgentsCardView {
}
}
}
new_state.fill_missing_agent_models();
// Re-seed an Unset selection from persisted per-harness settings,
// honoring an explicit `Inherit` choice for this harness.
if matches!(
@@ -699,10 +727,14 @@ impl RunAgentsCardView {
|| self.state.orch.execution_mode != new_state.orch.execution_mode;
self.state = new_state;
self.sync_configured_children();
self.ensure_agent_model_pickers(ctx);
self.repopulate_agent_model_pickers(ctx);
if harness_or_model_changed {
// Repopulate pickers and re-arm auto-open for the newly-
// streamed harness.
oc::repopulate_all_pickers(&mut self.state.orch, &self.handles.pickers, ctx);
self.reconcile_agent_model_selections(ctx);
self.repopulate_agent_model_pickers(ctx);
self.has_auto_opened_create_modal = false;
}
self.refresh_accept_button_state(ctx);
@@ -715,6 +747,76 @@ impl RunAgentsCardView {
sync_run_agents_children(&mut self.children, &self.state.agent_run_configs);
}
fn reconcile_agent_model_selections(&mut self, ctx: &mut ViewContext<Self>) {
let harness_type = self.state.orch.harness_type.clone();
let is_local = !self.state.orch.execution_mode.is_remote();
let fallback = if oc::is_model_in_filtered_choices(
&self.state.orch.model_id,
&harness_type,
is_local,
ctx,
) {
self.state.orch.model_id.clone()
} else {
oc::first_filtered_model_id(&harness_type, ctx).unwrap_or_default()
};
for config in &mut self.state.agent_run_configs {
if !oc::is_model_in_filtered_choices(&config.model_id, &harness_type, is_local, ctx) {
config.model_id = fallback.clone();
}
}
}
fn ensure_agent_model_pickers(&mut self, ctx: &mut ViewContext<Self>) {
let appearance = Appearance::as_ref(ctx);
let (styles, _) = oc::picker_styles(appearance);
let harness_type = self.state.orch.harness_type.clone();
let is_local = !self.state.orch.execution_mode.is_remote();
for (config, child) in self.state.agent_run_configs.iter().zip(&mut self.children) {
if child.model_picker.is_some() {
continue;
}
let handle = oc::new_standard_filterable_picker_dropdown(&styles, ctx);
Self::set_upward_filterable_menu_position(&handle, ctx);
let agent_name = config.name.clone();
oc::populate_model_picker_for_harness_with_action(
&handle,
&config.model_id,
&harness_type,
is_local,
move |model_id| RunAgentsCardViewAction::AgentModelChanged {
agent_name: agent_name.clone(),
model_id,
},
ctx,
);
Self::subscribe_filterable_picker_close(&handle, ctx);
child.model_picker = Some(handle);
}
}
fn repopulate_agent_model_pickers(&mut self, ctx: &mut ViewContext<Self>) {
let harness_type = self.state.orch.harness_type.clone();
let is_local = !self.state.orch.execution_mode.is_remote();
for (config, child) in self.state.agent_run_configs.iter().zip(&self.children) {
let Some(handle) = &child.model_picker else {
continue;
};
let agent_name = config.name.clone();
oc::populate_model_picker_for_harness_with_action(
handle,
&config.model_id,
&harness_type,
is_local,
move |model_id| RunAgentsCardViewAction::AgentModelChanged {
agent_name: agent_name.clone(),
model_id,
},
ctx,
);
}
}
fn link_child_conversation(&mut self, agent_name: &str, conversation_id: AIConversationId) {
if !link_run_agents_child(&mut self.children, agent_name, conversation_id) {
log::warn!(
@@ -1207,7 +1309,8 @@ impl View for RunAgentsCardView {
}
let is_blocked = matches!(status, Some(AIActionStatus::Blocked));
let card = render_confirmation_card(&self.state, &self.handles, is_blocked, app);
let card =
render_confirmation_card(&self.state, &self.handles, &self.children, is_blocked, app);
let mut root_stack = Stack::new();
root_stack.add_child(card);
@@ -1292,6 +1395,20 @@ impl TypedActionView for RunAgentsCardView {
self.refresh_accept_button_state(ctx);
ctx.notify();
}
RunAgentsCardViewAction::AgentModelChanged {
agent_name,
model_id,
} => {
if let Some(config) = self
.state
.agent_run_configs
.iter_mut()
.find(|config| config.name == *agent_name)
{
config.model_id = model_id.clone();
}
ctx.notify();
}
RunAgentsCardViewAction::HarnessChanged { harness_type } => {
let block_model = self.block_model.clone();
oc::apply_harness_change(
@@ -1302,6 +1419,8 @@ impl TypedActionView for RunAgentsCardView {
|ctx| block_model.base_model(ctx).map(|id| id.to_string()),
ctx,
);
self.reconcile_agent_model_selections(ctx);
self.repopulate_agent_model_pickers(ctx);
// Harness change resets per-harness selection state, so
// give the new harness a fresh auto-open prompt.
self.has_auto_opened_create_modal = false;
@@ -1441,6 +1560,7 @@ fn diverged_orch_fields_against_config(
fn render_confirmation_card(
state: &RunAgentsEditState,
handles: &RunAgentsCardHandles,
children: &[RunAgentsChildState],
is_blocked: bool,
app: &AppContext,
) -> Box<dyn Element> {
@@ -1448,7 +1568,7 @@ fn render_confirmation_card(
let theme = appearance.theme();
let header = render_header(handles, app);
let body = render_body(state, app);
let body = render_body(state, children, app);
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
@@ -1492,13 +1612,17 @@ fn render_header(handles: &RunAgentsCardHandles, app: &AppContext) -> Box<dyn El
config.render(app)
}
fn render_body(state: &RunAgentsEditState, app: &AppContext) -> Box<dyn Element> {
fn render_body(
state: &RunAgentsEditState,
children: &[RunAgentsChildState],
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
column.add_child(render_summary(state, appearance));
column.add_child(render_agents_section(state, app));
column.add_child(render_agents_section(state, children, app));
Container::new(column.finish())
.with_horizontal_padding(16.)
@@ -1532,7 +1656,11 @@ fn render_summary(state: &RunAgentsEditState, appearance: &Appearance) -> Box<dy
.finish()
}
fn render_agents_section(state: &RunAgentsEditState, app: &AppContext) -> Box<dyn Element> {
fn render_agents_section(
state: &RunAgentsEditState,
children: &[RunAgentsChildState],
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let label = Text::new(
@@ -1543,22 +1671,56 @@ fn render_agents_section(state: &RunAgentsEditState, app: &AppContext) -> Box<dy
.with_color(blended_colors::text_disabled(theme, theme.background()))
.finish();
let pills_row = Wrap::row()
let agent_cards = Wrap::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.)
.with_run_spacing(4.)
.with_spacing(12.)
.with_run_spacing(8.)
.with_children(
state
.agent_run_configs
.iter()
.map(|cfg| render_static_agent_pill(&cfg.name, app)),
.zip(children)
.map(|(config, child)| render_agent_model_card(config, child, app)),
)
.finish();
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Container::new(label).with_margin_bottom(6.).finish())
.with_child(pills_row)
.with_child(agent_cards)
.finish()
}
fn render_agent_model_card(
config: &RunAgentsAgentRunConfig,
child: &RunAgentsChildState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let model_label = Text::new(
"Model".to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size() - 1.,
)
.with_color(blended_colors::text_disabled(
appearance.theme(),
appearance.theme().background(),
))
.finish();
let picker = child
.model_picker
.as_ref()
.map(|picker| ChildView::new(picker).finish())
.unwrap_or_else(|| Empty::new().finish());
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(render_static_agent_pill(&config.name, app))
.with_child(Container::new(model_label).with_margin_top(6.).finish())
.with_child(picker)
.finish();
ConstrainedBox::new(content)
.with_width(oc::ORCHESTRATION_PICKER_MAX_WIDTH)
.finish()
}
@@ -1865,7 +2027,6 @@ fn render_editor(
handles: &RunAgentsCardHandles,
app: &AppContext,
) -> Box<dyn Element> {
use warpui::elements::ConstrainedBox;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
@@ -1889,11 +2050,24 @@ fn render_editor(
.with_margin_top(12.)
.finish(),
);
column.add_child(oc::render_picker_row(
&state.orch,
&handles.pickers,
appearance,
));
let harness_picker = handles
.pickers
.harness_picker
.as_ref()
.map(|picker| ChildView::new(picker).finish());
column.add_child(
Container::new(
ConstrainedBox::new(oc::render_picker_column(
"Agent harness",
harness_picker,
appearance,
))
.with_width(oc::ORCHESTRATION_PICKER_MAX_WIDTH)
.finish(),
)
.with_margin_top(12.)
.finish(),
);
if let Some(reason) = oc::accept_disabled_reason_with_auth(&state.orch, app) {
column.add_child(oc::render_validation_error(
@@ -37,6 +37,7 @@ fn make_request_with_skills(
name: "child".to_string(),
prompt: "do work".to_string(),
title: "Child agent".to_string(),
model_id: "auto".to_string(),
}],
plan_id: String::new(),
harness_auth_secret_name: None,
@@ -256,6 +257,21 @@ fn to_request_preserves_fields_but_normalizes_execution_to_local() {
assert_eq!(round_tripped.plan_id, req.plan_id);
}
#[test]
fn legacy_child_without_model_inherits_the_run_model() {
let mut request = make_request("oz", RunAgentsExecutionMode::Local);
request.model_id = "fallback-model".to_string();
request.agent_run_configs[0].model_id.clear();
let state = RunAgentsEditState::from_request(&request);
assert_eq!(state.agent_run_configs[0].model_id, "fallback-model");
assert_eq!(
state.to_request().agent_run_configs[0].model_id,
"fallback-model"
);
}
#[test]
fn live_child_links_and_removal_survive_streaming_config_sync() {
let first_id = AIConversationId::new();
@@ -275,11 +291,13 @@ fn live_child_links_and_removal_survive_streaming_config_sync() {
name: "gamma".to_string(),
prompt: "new work".to_string(),
title: String::new(),
model_id: "model-gamma".to_string(),
},
RunAgentsAgentRunConfig {
name: "alpha".to_string(),
prompt: "updated work".to_string(),
title: String::new(),
model_id: "model-alpha".to_string(),
},
];
sync_run_agents_children(&mut children, &configs);
+4 -1
View File
@@ -47,7 +47,7 @@ pub struct HarnessAvailability {
fn default_harnesses() -> Vec<HarnessAvailability> {
vec![HarnessAvailability {
harness: Harness::Oz,
display_name: "Warp".to_string(),
display_name: harness_display::display_name(Harness::Oz).to_string(),
enabled: true,
available_models: vec![],
}]
@@ -144,6 +144,9 @@ impl HarnessAvailabilityModel {
}
pub fn display_name_for(&self, harness: Harness) -> &str {
if harness == Harness::Oz {
return harness_display::display_name(harness);
}
self.harnesses
.iter()
.find(|h| h.harness == harness)
+1
View File
@@ -15,3 +15,4 @@ pub(crate) use provider_run_coordinator::{
pub(crate) use rig::{
prepare_provider_run, provider_runtime_for_request, PreparedProviderRun, ProviderActionContext,
};
pub(crate) use rig_request::OrchestrationModelOption;
+6 -3
View File
@@ -13,8 +13,9 @@ use uuid::Uuid;
use warp_multi_agent_api::ToolType;
use super::rig_request::{
prepare_bedrock_rig_turn_for_mode, prepare_rig_turn, prepare_rig_turn_for_mode, MCPToolTarget,
PreparedRigTurn, RigRequestMode,
add_orchestration_model_options, prepare_bedrock_rig_turn_for_mode, prepare_rig_turn,
prepare_rig_turn_for_mode, MCPToolTarget, OrchestrationModelOption, PreparedRigTurn,
RigRequestMode,
};
use super::rig_tool::action_from_tool_call;
use super::ProviderRunProfile;
@@ -76,6 +77,7 @@ pub(crate) async fn prepare_provider_run(
base_provider_config: crate::ai::provider::ProviderConfig,
cli_provider_config: crate::ai::provider::ProviderConfig,
mut params: RequestParams,
orchestration_models: Vec<OrchestrationModelOption>,
) -> anyhow::Result<PreparedProviderRun> {
let (supported_tools, supported_cli_agent_tools) =
crate::ai::agent::api::prepare_direct_provider_params(&mut params);
@@ -112,7 +114,7 @@ pub(crate) async fn prepare_provider_run(
}
};
let (base_runtime, prepared) = prepare_provider_profile(
let (base_runtime, mut prepared) = prepare_provider_profile(
base_provider_config,
params,
supported_tools.clone(),
@@ -120,6 +122,7 @@ pub(crate) async fn prepare_provider_run(
None,
)
.await?;
add_orchestration_model_options(&mut prepared.request.tools, &orchestration_models);
let (cli_runtime, cli_prepared) = prepare_provider_profile(
cli_provider_config,
cli_params,
+85
View File
@@ -33,6 +33,91 @@ pub(crate) struct PreparedRigTurn {
pub mcp_tool_aliases: HashMap<String, MCPToolTarget>,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct OrchestrationModelOption {
pub id: String,
pub display_name: String,
pub provider: String,
pub quality: Option<f32>,
pub cost: Option<f32>,
pub credit_multiplier: Option<f32>,
}
pub(crate) fn add_orchestration_model_options(
tools: &mut [ToolDefinition],
models: &[OrchestrationModelOption],
) {
if models.is_empty() {
return;
}
let Some(tool) = tools.iter_mut().find(|tool| tool.name == "run_agents") else {
return;
};
let catalog = models
.iter()
.map(|model| {
let mut details = vec![
format!("id={:?}", model.id),
format!("name={:?}", model.display_name),
format!("provider={:?}", model.provider),
];
if let Some(quality) = model.quality {
details.push(format!("quality_score={quality:.2}"));
}
if let Some(cost) = model.cost {
details.push(format!("cost_score={cost:.2}"));
}
if let Some(multiplier) = model.credit_multiplier {
details.push(format!("credit_multiplier={multiplier:.2}x"));
}
format!("- {}", details.join(", "))
})
.collect::<Vec<_>>()
.join("\n");
let description = format!(
"Required model for this child. Select exactly one available model ID. Prioritize the model best suited to the child's task and most likely to succeed. Among similarly capable models, prefer the lower-cost option; do not sacrifice material capability merely to choose the cheapest model. Cost scores represent relative consumption, with higher values costing more.\nAvailable models:\n{catalog}"
);
let model_ids = models
.iter()
.map(|model| serde_json::Value::String(model.id.clone()))
.collect::<Vec<_>>();
let Some(agent_items) = tool
.input_schema
.get_mut("properties")
.and_then(|properties| properties.get_mut("agent_run_configs"))
.and_then(|configs| configs.get_mut("items"))
else {
return;
};
let Some(properties) = agent_items
.get_mut("properties")
.and_then(serde_json::Value::as_object_mut)
else {
return;
};
properties.insert(
"model_id".to_string(),
serde_json::json!({
"type": "string",
"enum": model_ids,
"description": description,
}),
);
let Some(required) = agent_items
.get_mut("required")
.and_then(serde_json::Value::as_array_mut)
else {
return;
};
if !required.iter().any(|field| field == "model_id") {
required.push(serde_json::Value::String("model_id".to_string()));
}
tool.description = "Start one or more child agents. Assign each child the best-fit, cost-effective model from its required model_id choices, prioritizing capability and likelihood of success over price. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. After launch, call wait_for_events when you need child-agent results instead of repeating their work yourself.".to_string();
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct MCPToolTarget {
pub server_id: Option<Uuid>,
+47 -2
View File
@@ -8,8 +8,8 @@ use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use warp_multi_agent_api::ToolType;
use super::{
input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, prepare_rig_turn_for_mode,
tool_definitions, RigRequestMode,
add_orchestration_model_options, input_messages, prepare_bedrock_rig_turn, prepare_rig_turn,
prepare_rig_turn_for_mode, tool_definitions, OrchestrationModelOption, RigRequestMode,
};
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::task::TaskId;
@@ -631,6 +631,51 @@ fn modern_and_legacy_orchestration_tools_follow_subagent_capabilities() {
.any(|tool| matches!(tool.name.as_str(), "run_agents" | "start_agent")));
}
#[test]
fn run_agents_tool_requires_a_best_fit_model_from_the_available_catalog() {
let (mut tools, _) = tool_definitions(&[ToolType::Subagent], None);
add_orchestration_model_options(
&mut tools,
&[
OrchestrationModelOption {
id: "strong-model".to_string(),
display_name: "Strong Model".to_string(),
provider: "Provider A".to_string(),
quality: Some(0.95),
cost: Some(0.8),
credit_multiplier: Some(2.0),
},
OrchestrationModelOption {
id: "efficient-model".to_string(),
display_name: "Efficient Model".to_string(),
provider: "Provider B".to_string(),
quality: Some(0.9),
cost: Some(0.3),
credit_multiplier: Some(0.5),
},
],
);
let run_agents = tools
.iter()
.find(|tool| tool.name == "run_agents")
.expect("run_agents tool");
let model = &run_agents.input_schema["properties"]["agent_run_configs"]["items"]["properties"]
["model_id"];
assert_eq!(
model["enum"],
serde_json::json!(["strong-model", "efficient-model"])
);
assert!(model["description"]
.as_str()
.is_some_and(|description| description.contains("Prioritize the model best suited")));
assert!(
run_agents.input_schema["properties"]["agent_run_configs"]["items"]["required"]
.as_array()
.is_some_and(|required| required.iter().any(|field| field == "model_id"))
);
}
#[test]
#[allow(deprecated)]
fn grouped_mcp_tool_names_use_the_installation_id_not_the_display_name() {
+1
View File
@@ -31,6 +31,7 @@ async fn missing_cli_provider_route_falls_back_to_base_provider_profile() {
ProviderConfig::OpenAI(openai_config("base-provider-model")),
ProviderConfig::None,
params,
Vec::new(),
)
.await
.unwrap();
+1
View File
@@ -181,6 +181,7 @@ pub(crate) fn action_from_tool_call(
name: required_nonempty_string(config, "name")?,
prompt: required_nonempty_string(config, "prompt")?,
title: optional_string(config, "title")?.unwrap_or_default(),
model_id: optional_string(config, "model_id")?.unwrap_or_default(),
})
})
.collect::<Result<_, String>>()?,
+4 -1
View File
@@ -444,7 +444,8 @@ fn run_agents_calls_decode_to_local_domain_requests_with_safe_defaults() {
{
"name": "runtime",
"prompt": "Inspect runtime behavior",
"title": "Runtime investigator"
"title": "Runtime investigator",
"model_id": "strong-model"
},
{
"name": "tests",
@@ -476,9 +477,11 @@ fn run_agents_calls_decode_to_local_domain_requests_with_safe_defaults() {
"Inspect runtime behavior"
);
assert_eq!(request.agent_run_configs[0].title, "Runtime investigator");
assert_eq!(request.agent_run_configs[0].model_id, "strong-model");
assert_eq!(request.agent_run_configs[1].name, "tests");
assert_eq!(request.agent_run_configs[1].prompt, "Design focused tests");
assert!(request.agent_run_configs[1].title.is_empty());
assert!(request.agent_run_configs[1].model_id.is_empty());
}
#[test]