Skip to content
Draft
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
4 changes: 4 additions & 0 deletions app/src/ai/blocklist/action_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ impl BlocklistAIActionModel {
self.is_view_only = is_view_only;
}

pub(crate) fn terminal_view_id(&self) -> EntityId {
self.terminal_view_id
}

/// Marks an action as remotely executing on the viewer side.
/// This is called when a viewer receives a CommandExecutionStarted event from the sharer,
/// allowing the viewer's UI to show the action as running even though it's not executing locally.
Expand Down
51 changes: 45 additions & 6 deletions app/src/ai/blocklist/action_model/execute/run_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ use crate::ai::agent::{
StartAgentExecutionMode,
};
use crate::ai::auth_secret_types::auth_secret_types_for_harness;
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
use crate::ai::blocklist::inline_action::orchestration_controls::{
normalize_orchestration_model, remote_oz_model_incompatible, OrchestrationEditState,
};
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
use crate::ai::cloud_agent_settings::CloudAgentSettings;
use crate::ai::document::plan_publication::{
prepare_plan_publications, wait_for_plan_publications,
};
use crate::ai::llms::LLMPreferences;
use crate::ai::local_harness_setup::local_harness_product_disabled_message;

/// Per-child spawn timeout. If a child agent doesn't report back within
Expand Down Expand Up @@ -170,7 +173,7 @@ impl RunAgentsExecutor {
return receiver;
}

if let Err(error) = validate_request(&request) {
if let Err(error) = validate_request(&request, ctx) {
log::warn!("RunAgentsExecutor: validation failure: {error}");
let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver;
Expand Down Expand Up @@ -414,6 +417,11 @@ impl RunAgentsExecutor {
}
let mut resolved_request = request.clone();
resolve_request_from_approved_config(&mut resolved_request, input.conversation_id, ctx);
populate_default_orchestration_model_for_execution(
&mut resolved_request,
self.terminal_view_id,
ctx,
);
populate_default_auth_secret_for_execution(&mut resolved_request, ctx);
if self
.duplicate_launched_agents_reason(&resolved_request, input.conversation_id, ctx)
Expand Down Expand Up @@ -483,6 +491,7 @@ fn prepare_request_for_execution(
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_orchestration_model_for_execution(request, terminal_view_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
if let Some(reason) =
duplicate_launched_agents_reason(request, parent_conversation_id, launched_agents, ctx)
Expand Down Expand Up @@ -669,9 +678,25 @@ fn populate_default_auth_secret_for_execution(
default_auth_secret_name_for_harness(&request.harness_type, ctx);
}

fn populate_default_orchestration_model_for_execution(
request: &mut RunAgentsRequest,
terminal_view_id: EntityId,
ctx: &ModelContext<RunAgentsExecutor>,
) {
let profile_model_id = LLMPreferences::as_ref(ctx)
.get_active_orchestration_model(ctx, Some(terminal_view_id))
.map(|model| model.id.clone());
let mut edit_state = OrchestrationEditState::from_run_agents_fields(
&request.model_id,
&request.harness_type,
&request.execution_mode,
);
normalize_orchestration_model(&mut edit_state, None, profile_model_id.as_ref());
request.model_id = edit_state.model_id;
}

/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
/// from the approved orchestration config, delegating to
/// `OrchestrationEditState::override_from_approved_config`.
/// from the approved orchestration config through the shared normalizer.
fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &OrchestrationConfig) {
// The approved plan config is the source of truth for these run-wide fields,
// so callers pass a mutable request and continue with the normalized value.
Expand All @@ -680,15 +705,18 @@ fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &Orchestr
&request.harness_type,
&request.execution_mode,
);
edit_state.override_from_approved_config(config);
normalize_orchestration_model(&mut edit_state, Some(config), None);
request.model_id = edit_state.model_id;
request.harness_type = edit_state.harness_type;
request.execution_mode = edit_state.execution_mode;
}

/// Defence-in-depth validation; mirrors the card view's
/// `accept_disabled_reason` check.
fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
fn validate_request(
request: &RunAgentsRequest,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Result<(), String> {
if request.agent_run_configs.is_empty() {
return Err("orchestrate: empty agent_run_configs".to_string());
}
Expand All @@ -706,6 +734,17 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
{
return Err("Remote child agents do not support the opencode harness yet.".to_string());
}
let edit_state = OrchestrationEditState::from_run_agents_fields(
&request.model_id,
&request.harness_type,
&request.execution_mode,
);
if remote_oz_model_incompatible(&edit_state, LLMPreferences::as_ref(ctx)) {
return Err(
"Selected orchestration model is only available locally. Choose a cloud-runnable model or router to continue."
.to_string(),
);
}
Ok(())
}

Expand Down
84 changes: 52 additions & 32 deletions app/src/ai/blocklist/inline_action/orchestration_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ use crate::ai::connected_self_hosted_workers::{ConnectedSelfHostedWorkersModel,
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel};
use crate::ai::harness_display;
use crate::ai::llms::LLMInfo;
use crate::ai::llms::{LLMId, LLMInfo};
use crate::ai::local_harness_setup::{
local_harness_is_product_enabled, local_harness_product_disabled_message,
local_harness_setup_state, LocalHarnessSetupState,
Expand Down Expand Up @@ -166,6 +166,36 @@ impl OrchestrationEditState {
}
}

/// Applies approved-plan precedence, then fills only an omitted Oz/default-harness
/// model from the active execution profile.
pub fn normalize_orchestration_model(
state: &mut OrchestrationEditState,
approved_config: Option<&OrchestrationConfig>,
profile_model_id: Option<&LLMId>,
) {
if let Some(config) = approved_config {
state.override_from_approved_config(config);
}
let harness = Harness::parse_orchestration_harness(&state.harness_type);
if state.model_id.trim().is_empty() && matches!(harness, Some(Harness::Oz) | None) {
if let Some(model_id) = profile_model_id {
state.model_id = model_id.to_string();
}
}
}

pub(crate) fn remote_oz_model_incompatible(
state: &OrchestrationEditState,
llm_preferences: &LLMPreferences,
) -> bool {
if !state.execution_mode.is_remote() || state.model_id.trim().is_empty() {
return false;
}
let harness = Harness::parse_orchestration_harness(&state.harness_type);
matches!(harness, Some(Harness::Oz) | None)
&& !llm_preferences.is_cloud_runnable_oz_model_id(&LLMId::from(state.model_id.as_str()))
}

impl OrchestrationEditState {
pub(crate) fn sanitize_for_local_execution(&mut self) {
let Some(harness) = Harness::parse_local_child_harness(&self.harness_type) else {
Expand Down Expand Up @@ -268,26 +298,6 @@ impl OrchestrationEditState {
}
}

/// Fills in empty fields from the approved orchestration config.
/// When the LLM omits harness/model/execution_mode to inherit from
/// the active config, the raw request arrives with defaults (empty
/// harness, empty model, Local mode). This resolves those to the
/// config values so the UI shows the intended settings.
pub fn resolve_from_config(&mut self, config: &OrchestrationConfig) {
if self.harness_type.is_empty() && !config.harness_type.is_empty() {
self.harness_type = config.harness_type.clone();
}
if self.model_id.is_empty() && !config.model_id.is_empty() {
self.model_id = config.model_id.clone();
}
if !self.execution_mode.is_remote() && config.execution_mode.is_remote() {
self.execution_mode = Self::from_orchestration_config(config).execution_mode;
}
if matches!(self.execution_mode, RunAgentsExecutionMode::Local) {
self.sanitize_for_local_execution();
}
}

/// Unconditionally overrides model, harness, and execution mode
/// from the approved orchestration config. The plan config is the
/// user-approved source of truth — the LLM's run_agents call may
Expand Down Expand Up @@ -481,7 +491,7 @@ fn get_base_model_choices<'a>(
) -> impl Iterator<Item = &'a LLMInfo> {
llm_prefs
.get_base_llm_choices_for_agent_mode(app)
.filter(move |llm| is_local || llm_prefs.custom_llm_info_for_id(&llm.id).is_none())
.filter(move |llm| is_local || llm_prefs.is_cloud_runnable_oz_model_id(&llm.id))
}
/// Populates the model picker based on the active harness.
///
Expand Down Expand Up @@ -630,14 +640,14 @@ pub fn is_model_in_filtered_choices<V: View>(
/// string (the "Default model" entry).
pub fn first_filtered_model_id<V: View>(
harness_type: &str,
is_local: bool,
ctx: &mut ViewContext<V>,
) -> Option<String> {
let harness = Harness::parse_orchestration_harness(harness_type);
match harness {
Some(Harness::Oz) | None => {
let llm_prefs = LLMPreferences::as_ref(ctx);
llm_prefs
.get_base_llm_choices_for_agent_mode(ctx)
get_base_model_choices(llm_prefs, ctx, is_local)
.next()
.map(|llm| llm.id.to_string())
}
Expand Down Expand Up @@ -1174,6 +1184,12 @@ pub fn accept_disabled_reason_with_auth(
if auth_secret_selection_required(state, ctx) {
return Some("Select an API key for this harness to continue.".to_string());
}
if remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx)) {
return Some(
"Selected orchestration model is only available locally. Choose a cloud-runnable model or router to continue."
.to_string(),
);
}
None
}

Expand Down Expand Up @@ -1402,12 +1418,12 @@ pub fn apply_harness_change<A: OrchestrationControlAction, V: View>(
.cloned();
if let Some(saved_id) = restored {
state.model_id = saved_id;
} else if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) {
// No saved model — fall back to conversation base model
// for Oz, or default for non-Oz.
} else if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx)
&& !remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx))
{
let reset_id = fallback_base_model_id(ctx)
.filter(|id| is_model_in_filtered_choices(id, &state.harness_type, is_local, ctx))
.or_else(|| first_filtered_model_id(&state.harness_type, ctx))
.or_else(|| first_filtered_model_id(&state.harness_type, is_local, ctx))
.unwrap_or_default();
state.model_id = reset_id;
}
Expand Down Expand Up @@ -1459,10 +1475,12 @@ pub fn apply_execution_mode_change<A: OrchestrationControlAction, V: View>(
}
}
}
if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) {
if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx)
&& !remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx))
{
let reset_id = fallback_base_model_id(ctx)
.filter(|id| is_model_in_filtered_choices(id, &state.harness_type, is_local, ctx))
.or_else(|| first_filtered_model_id(&state.harness_type, ctx))
.or_else(|| first_filtered_model_id(&state.harness_type, is_local, ctx))
.unwrap_or_default();
state.model_id = reset_id;
}
Expand Down Expand Up @@ -1504,8 +1522,10 @@ pub fn repopulate_all_pickers<A: OrchestrationControlAction, V: View>(
populate_harness_picker(handle, &state.harness_type, is_local, ctx);
}
// Reset model if it disappeared from the harness's catalog.
if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) {
if let Some(first_id) = first_filtered_model_id(&state.harness_type, ctx) {
if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx)
&& !remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx))
{
if let Some(first_id) = first_filtered_model_id(&state.harness_type, is_local, ctx) {
state.model_id = first_id;
}
}
Expand Down
Loading