Improve Galaxy permissions, computer-use routing, and agent UX
This commit is contained in:
@@ -15,7 +15,6 @@
|
||||
mod execute;
|
||||
mod preprocess;
|
||||
mod risk_review;
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -43,6 +42,7 @@ use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, Singleto
|
||||
use itertools::Itertools;
|
||||
use parking_lot::FairMutex;
|
||||
use preprocess::{PendingPreprocessedActions, PreprocessId};
|
||||
pub(crate) use risk_review::{ReviewDisplay, ReviewDisplayState};
|
||||
|
||||
use self::execute::ask_user_question::AskUserQuestionExecutor;
|
||||
use self::execute::search_codebase::SearchCodebaseExecutor;
|
||||
@@ -784,6 +784,7 @@ pub struct BlocklistAIActionModel {
|
||||
denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
|
||||
pending_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
|
||||
action_reviews: HashMap<(AIConversationId, AIAgentActionId), risk_review::ActionReview>,
|
||||
review_outcomes: HashMap<(AIConversationId, AIAgentActionId), risk_review::ReviewOutcome>,
|
||||
permission_denial_reasons: HashMap<(AIConversationId, AIAgentActionId), String>,
|
||||
|
||||
/// Actions parked because their executor-specific UI or state was not ready yet.
|
||||
@@ -911,6 +912,7 @@ impl BlocklistAIActionModel {
|
||||
denied_permissions: Default::default(),
|
||||
pending_permissions: Default::default(),
|
||||
action_reviews: Default::default(),
|
||||
review_outcomes: Default::default(),
|
||||
permission_denial_reasons: Default::default(),
|
||||
not_ready_actions: Default::default(),
|
||||
deferred_provider_preprocessing: Default::default(),
|
||||
@@ -2214,6 +2216,15 @@ impl BlocklistAIActionModel {
|
||||
pending_action: &AIAgentAction,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self
|
||||
.review_reason(conversation_id, &pending_action.id)
|
||||
.is_some()
|
||||
{
|
||||
self.review_outcomes.insert(
|
||||
(conversation_id, pending_action.id.clone()),
|
||||
risk_review::ReviewOutcome::denied_by_user(pending_action),
|
||||
);
|
||||
}
|
||||
self.denied_permissions
|
||||
.insert((conversation_id, pending_action.id.clone()));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -2363,6 +2374,8 @@ impl BlocklistAIActionModel {
|
||||
.retain(|(conv_id, _)| *conv_id != conversation_id);
|
||||
self.action_reviews
|
||||
.retain(|(conv_id, _), _| *conv_id != conversation_id);
|
||||
self.review_outcomes
|
||||
.retain(|(conv_id, _), _| *conv_id != conversation_id);
|
||||
self.permission_denial_reasons
|
||||
.retain(|(conv_id, _), _| *conv_id != conversation_id);
|
||||
self.pending_actions.remove(&conversation_id);
|
||||
|
||||
@@ -84,6 +84,57 @@ enum ReviewDecision {
|
||||
Ask { reason: String, deadline: Instant },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ReviewDisplayState {
|
||||
Assessing,
|
||||
AwaitingApproval,
|
||||
Approved,
|
||||
Denied,
|
||||
}
|
||||
|
||||
pub(crate) struct ReviewDisplay {
|
||||
pub(crate) state: ReviewDisplayState,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) detail: String,
|
||||
}
|
||||
|
||||
pub(super) struct ReviewOutcome {
|
||||
summary: String,
|
||||
detail: String,
|
||||
approved: bool,
|
||||
}
|
||||
|
||||
impl ReviewOutcome {
|
||||
pub(super) fn denied_by_user(action: &AIAgentAction) -> Self {
|
||||
Self {
|
||||
summary: review_summary(action),
|
||||
detail: "Denied by you.".to_string(),
|
||||
approved: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn review_summary(action: &AIAgentAction) -> String {
|
||||
let description = match &action.action {
|
||||
AIAgentActionType::RequestCommandOutput { command, .. } => {
|
||||
format!("Run: {command}")
|
||||
}
|
||||
_ => action.action.user_friendly_name(),
|
||||
};
|
||||
let summary = description.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
shorten_for_review(&summary, 160)
|
||||
}
|
||||
|
||||
fn shorten_for_review(text: &str, limit: usize) -> String {
|
||||
let mut characters = text.chars();
|
||||
let shortened = characters.by_ref().take(limit).collect::<String>();
|
||||
if characters.next().is_some() {
|
||||
format!("{shortened}…")
|
||||
} else {
|
||||
shortened
|
||||
}
|
||||
}
|
||||
|
||||
impl BlocklistAIActionModel {
|
||||
fn review_environment(&self, ctx: &AppContext) -> ReviewEnvironment {
|
||||
let session = self.active_session.as_ref(ctx);
|
||||
@@ -267,6 +318,7 @@ impl BlocklistAIActionModel {
|
||||
&& self.review_requires_human(action, conversation_id, ctx))
|
||||
}) {
|
||||
self.action_reviews.remove(&key);
|
||||
self.review_outcomes.remove(&key);
|
||||
}
|
||||
if let Some(ActionReview {
|
||||
decision: ReviewDecision::Ask { deadline, .. },
|
||||
@@ -279,6 +331,20 @@ impl BlocklistAIActionModel {
|
||||
}
|
||||
}
|
||||
if user_initiated || !self.uses_galaxy_decides(action, ctx) {
|
||||
if user_initiated {
|
||||
if let Some(review) = self.action_reviews.get(&key) {
|
||||
if let ReviewDecision::Ask { reason, .. } = &review.decision {
|
||||
self.review_outcomes.insert(
|
||||
key.clone(),
|
||||
ReviewOutcome {
|
||||
summary: review_summary(action),
|
||||
detail: format!("Approved by you. {reason}"),
|
||||
approved: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.action_reviews.remove(&key);
|
||||
return Some(false);
|
||||
}
|
||||
@@ -289,6 +355,7 @@ impl BlocklistAIActionModel {
|
||||
};
|
||||
}
|
||||
let token = uuid::Uuid::new_v4();
|
||||
self.review_outcomes.remove(&key);
|
||||
let (cancel, control) = turn_control();
|
||||
let execution_ref = self.provider_tool_execution_ref(conversation_id, &action.id);
|
||||
self.action_reviews.insert(
|
||||
@@ -408,6 +475,18 @@ impl BlocklistAIActionModel {
|
||||
.as_ref()
|
||||
.is_ok_and(|assessment| assessment.allows_automatic_execution(policy_requires_human))
|
||||
{
|
||||
let assessment = result.as_ref().unwrap();
|
||||
self.review_outcomes.insert(
|
||||
key.clone(),
|
||||
ReviewOutcome {
|
||||
summary: review_summary(&action),
|
||||
detail: format!(
|
||||
"Approved by Galaxy · risk {}/5. {}",
|
||||
assessment.risk, assessment.reason
|
||||
),
|
||||
approved: true,
|
||||
},
|
||||
);
|
||||
self.action_reviews.get_mut(&key).unwrap().decision = ReviewDecision::Allow;
|
||||
self.try_to_execute_available_actions(conversation_id, ctx);
|
||||
return;
|
||||
@@ -486,6 +565,43 @@ impl BlocklistAIActionModel {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn review_display(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
action_id: &AIAgentActionId,
|
||||
) -> Option<ReviewDisplay> {
|
||||
let key = (conversation_id, action_id.clone());
|
||||
if let Some(review) = self.action_reviews.get(&key) {
|
||||
let summary = review_summary(&review.action);
|
||||
return Some(match &review.decision {
|
||||
ReviewDecision::Assessing => ReviewDisplay {
|
||||
state: ReviewDisplayState::Assessing,
|
||||
summary,
|
||||
detail: "Galaxy is assessing the permission risk…".to_string(),
|
||||
},
|
||||
ReviewDecision::Ask { reason, .. } => ReviewDisplay {
|
||||
state: ReviewDisplayState::AwaitingApproval,
|
||||
summary,
|
||||
detail: shorten_for_review(reason, 320),
|
||||
},
|
||||
ReviewDecision::Allow => ReviewDisplay {
|
||||
state: ReviewDisplayState::Approved,
|
||||
summary,
|
||||
detail: shorten_for_review(&self.review_outcomes.get(&key)?.detail, 320),
|
||||
},
|
||||
});
|
||||
}
|
||||
self.review_outcomes.get(&key).map(|outcome| ReviewDisplay {
|
||||
state: if outcome.approved {
|
||||
ReviewDisplayState::Approved
|
||||
} else {
|
||||
ReviewDisplayState::Denied
|
||||
},
|
||||
summary: outcome.summary.clone(),
|
||||
detail: shorten_for_review(&outcome.detail, 320),
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_reviewed_action(
|
||||
&mut self,
|
||||
action: &AIAgentAction,
|
||||
@@ -507,6 +623,14 @@ impl BlocklistAIActionModel {
|
||||
return;
|
||||
};
|
||||
let action = queue.remove(index).unwrap();
|
||||
self.review_outcomes.insert(
|
||||
key.clone(),
|
||||
ReviewOutcome {
|
||||
summary: review_summary(&action),
|
||||
detail: "Denied automatically after 60 seconds without a response.".to_string(),
|
||||
approved: false,
|
||||
},
|
||||
);
|
||||
self.pending_permissions.remove(&key);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
log_tool_event(
|
||||
|
||||
@@ -4918,6 +4918,7 @@ impl AIBlock {
|
||||
}
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
|
||||
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
|
||||
me.abort_auto_expand_requested_command_timer();
|
||||
|
||||
@@ -70,7 +70,7 @@ use crate::ai::agent::{
|
||||
};
|
||||
use crate::ai::agent_conversations_model::AgentConversationsModel;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::action_model::AIActionStatus;
|
||||
use crate::ai::blocklist::action_model::{AIActionStatus, ReviewDisplay, ReviewDisplayState};
|
||||
use crate::ai::blocklist::block::model::{AIBlockModel, AIBlockModelHelper, AIBlockOutputStatus};
|
||||
use crate::ai::blocklist::block::view_impl::common::{
|
||||
MaybeShimmeringText, BLOCKED_ACTION_MESSAGE_FOR_GREP_OR_FILE_GLOB,
|
||||
@@ -319,23 +319,12 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||
|
||||
for output_message in output.messages.iter() {
|
||||
if let AIAgentOutputMessageType::Action(action) = &output_message.message {
|
||||
if let Some(reason) = props
|
||||
if let Some(review) = props
|
||||
.action_model
|
||||
.as_ref(app)
|
||||
.review_reason(props.conversation_id, &action.id)
|
||||
.review_display(props.conversation_id, &action.id)
|
||||
{
|
||||
output_items.add_child(
|
||||
Text::new(
|
||||
reason,
|
||||
appearance.ai_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().surface_1(),
|
||||
))
|
||||
.finish(),
|
||||
);
|
||||
output_items.add_child(render_review_status(review, app));
|
||||
}
|
||||
}
|
||||
match &output_message.message {
|
||||
@@ -1331,6 +1320,55 @@ fn render_streaming_tool_call(
|
||||
render_tool_pane_shell(Clipped::new(header).finish(), false, false, false, app)
|
||||
}
|
||||
|
||||
fn render_review_status(review: ReviewDisplay, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let icon = match review.state {
|
||||
ReviewDisplayState::Assessing => Icon::Loading.to_galaxyui_icon(
|
||||
galaxy_core::ui::theme::Fill::Solid(internal_colors::neutral_6(appearance.theme())),
|
||||
),
|
||||
ReviewDisplayState::AwaitingApproval => icons::yellow_stop_icon(appearance),
|
||||
ReviewDisplayState::Approved => inline_action_icons::green_check_icon(appearance),
|
||||
ReviewDisplayState::Denied => inline_action_icons::red_x_icon(appearance),
|
||||
};
|
||||
let title = match review.state {
|
||||
ReviewDisplayState::Assessing => "Checking permission",
|
||||
ReviewDisplayState::AwaitingApproval => "Approval required · 60 seconds",
|
||||
ReviewDisplayState::Approved => "Approved",
|
||||
ReviewDisplayState::Denied => "Denied",
|
||||
};
|
||||
let header = HeaderConfig::new(title, app)
|
||||
.with_icon(icon)
|
||||
.with_corner_radius_override(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.render(app);
|
||||
let detail = format!("{}\n{}", review.summary, review.detail);
|
||||
let body = Container::new(
|
||||
Text::new(
|
||||
detail,
|
||||
appearance.ai_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
appearance.theme(),
|
||||
appearance.theme().surface_1(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(16.)
|
||||
.with_margin_right(16.)
|
||||
.with_margin_bottom(12.)
|
||||
.finish();
|
||||
render_tool_pane_shell(
|
||||
Flex::column()
|
||||
.with_child(Clipped::new(header).finish())
|
||||
.with_child(body)
|
||||
.finish(),
|
||||
matches!(review.state, ReviewDisplayState::AwaitingApproval),
|
||||
false,
|
||||
false,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_runtime_activity(
|
||||
output_message: &AIAgentOutputMessage,
|
||||
activity: &RuntimeActivity,
|
||||
@@ -3084,9 +3122,32 @@ fn render_use_computer(
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
|
||||
let mut renderable_action = RenderableAction::new(&request.action_summary, app)
|
||||
.with_icon(action_icon(action_id, props.action_model, props.model, app).finish());
|
||||
let status = props
|
||||
.action_model
|
||||
.as_ref(app)
|
||||
.get_action_status(props.conversation_id, action_id);
|
||||
let mut renderable_action = RenderableAction::new(&request.action_summary, app);
|
||||
if status.as_ref().is_some_and(|status| status.is_blocked()) {
|
||||
let buttons = props
|
||||
.action_buttons
|
||||
.get(action_id)
|
||||
.expect("Button states must exist for each requested action.");
|
||||
renderable_action = renderable_action
|
||||
.with_header(blocked_action_header(
|
||||
action_id.clone(),
|
||||
"OK if I use computer control for this action?",
|
||||
buttons.run_button.clone(),
|
||||
buttons.cancel_button.clone(),
|
||||
props.action_model,
|
||||
props.model,
|
||||
app,
|
||||
))
|
||||
.with_highlighted_border()
|
||||
.with_background_color(appearance.theme().background().into_solid());
|
||||
} else {
|
||||
renderable_action = renderable_action
|
||||
.with_icon(action_icon(action_id, props.action_model, props.model, app).finish());
|
||||
}
|
||||
|
||||
// Add a "View screenshot" button if the action result contains a screenshot.
|
||||
let has_screenshot = props
|
||||
|
||||
@@ -87,6 +87,7 @@ use crate::ai::runtime::{
|
||||
ProviderRunCoordinator, ProviderRunProfile, ProviderRunProjection,
|
||||
ProviderRunResponseProjector, ProviderToolExecutionRef, ProviderToolLifecycleOutcome,
|
||||
RuntimeResponseConfig, BASE_PROVIDER_PROFILE, CLI_MONITOR_PROVIDER_PROFILE,
|
||||
COMPUTER_USE_PROVIDER_PROFILE,
|
||||
};
|
||||
use crate::ai::AIRequestUsageModel;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
@@ -1206,6 +1207,7 @@ struct ActiveProviderRunCheckpoint {
|
||||
run: ProviderRun,
|
||||
base_request: TurnRequest,
|
||||
cli_monitor_request: Option<TurnRequest>,
|
||||
computer_use_request: Option<TurnRequest>,
|
||||
response_config: RuntimeResponseConfig,
|
||||
action_context: ProviderActionContext,
|
||||
persistence_offset: usize,
|
||||
@@ -1229,10 +1231,15 @@ impl ActiveProviderRunCheckpoint {
|
||||
.coordinator
|
||||
.profile_request(CLI_MONITOR_PROVIDER_PROFILE)
|
||||
.cloned();
|
||||
let computer_use_request = run
|
||||
.coordinator
|
||||
.profile_request(COMPUTER_USE_PROVIDER_PROFILE)
|
||||
.cloned();
|
||||
Ok(Self {
|
||||
run: run.coordinator.run().clone(),
|
||||
base_request,
|
||||
cli_monitor_request,
|
||||
computer_use_request,
|
||||
response_config: run.response_config.clone(),
|
||||
action_context: run.action_context.clone(),
|
||||
persistence_offset: run.persistence_offset,
|
||||
@@ -1255,6 +1262,8 @@ struct ActiveProviderRunSnapshot {
|
||||
run: ProviderRun,
|
||||
base_request: TurnRequest,
|
||||
cli_monitor_request: Option<TurnRequest>,
|
||||
#[serde(default)]
|
||||
computer_use_request: Option<TurnRequest>,
|
||||
response_config: RuntimeResponseConfig,
|
||||
action_context: ProviderActionContext,
|
||||
projection_target: ProviderProjectionTarget,
|
||||
@@ -1303,6 +1312,7 @@ impl ActiveProviderRunSnapshot {
|
||||
run: checkpoint.run,
|
||||
base_request: checkpoint.base_request,
|
||||
cli_monitor_request: checkpoint.cli_monitor_request,
|
||||
computer_use_request: checkpoint.computer_use_request,
|
||||
response_config: checkpoint.response_config,
|
||||
action_context: checkpoint.action_context,
|
||||
projection_target: slot.projection_target.clone(),
|
||||
@@ -1372,6 +1382,13 @@ impl ActiveProviderRunSnapshot {
|
||||
"provider run uses the CLI profile without a persisted request".to_string(),
|
||||
);
|
||||
}
|
||||
COMPUTER_USE_PROVIDER_PROFILE if self.computer_use_request.is_some() => {}
|
||||
COMPUTER_USE_PROVIDER_PROFILE => {
|
||||
return Err(
|
||||
"provider run uses the computer-use profile without a persisted request"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
profile => {
|
||||
return Err(format!(
|
||||
"provider run uses unknown request profile '{profile}'"
|
||||
@@ -2212,18 +2229,18 @@ fn provider_run_terminal_remote_log_record(
|
||||
|
||||
enum ProviderDriveMessage {
|
||||
Projection {
|
||||
lifecycle: Option<ProviderLlmLifecycle>,
|
||||
lifecycle: Box<Option<ProviderLlmLifecycle>>,
|
||||
latest_usage: Option<Usage>,
|
||||
tool_call_progress: ProviderToolCallProgressUpdate,
|
||||
events: Vec<warp_multi_agent_api::ResponseEvent>,
|
||||
acknowledgement: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
Checkpoint {
|
||||
checkpoint: ActiveProviderRunCheckpoint,
|
||||
checkpoint: Box<ActiveProviderRunCheckpoint>,
|
||||
acknowledgement: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
Blocked {
|
||||
run: ActiveProviderRun,
|
||||
run: Box<ActiveProviderRun>,
|
||||
result: Result<ProviderRunBlock, String>,
|
||||
},
|
||||
}
|
||||
@@ -5881,6 +5898,10 @@ impl BlocklistAIController {
|
||||
.cli_monitor_request
|
||||
.as_ref()
|
||||
.map(|request| ResponseStream::resolve_provider_config(request.model.as_str(), ctx));
|
||||
let computer_use_provider_config = snapshot
|
||||
.computer_use_request
|
||||
.as_ref()
|
||||
.map(|request| ResponseStream::resolve_provider_config(request.model.as_str(), ctx));
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
let base_runtime =
|
||||
@@ -5910,6 +5931,23 @@ impl BlocklistAIController {
|
||||
.with_max_context_tokens(cli_context_limit),
|
||||
);
|
||||
}
|
||||
if let Some(computer_use_request) = snapshot.computer_use_request.as_ref() {
|
||||
let provider_config = computer_use_provider_config.ok_or_else(|| {
|
||||
anyhow!("restored computer-use request is missing its provider config")
|
||||
})?;
|
||||
let context_limit = provider_context_window_tokens(
|
||||
&provider_config,
|
||||
computer_use_request.model.as_str(),
|
||||
base_context_limit,
|
||||
);
|
||||
let runtime =
|
||||
provider_runtime_for_request(provider_config, computer_use_request).await?;
|
||||
profiles.insert(
|
||||
COMPUTER_USE_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(runtime, computer_use_request.clone())
|
||||
.with_max_context_tokens(context_limit),
|
||||
);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(PreparedRestoredProviderRun {
|
||||
snapshot,
|
||||
profiles,
|
||||
@@ -6071,6 +6109,7 @@ impl BlocklistAIController {
|
||||
run: provider_run,
|
||||
base_request: _,
|
||||
cli_monitor_request: _,
|
||||
computer_use_request: _,
|
||||
response_config,
|
||||
action_context,
|
||||
projection_target,
|
||||
@@ -6533,11 +6572,16 @@ impl BlocklistAIController {
|
||||
credit_multiplier: model.usage_metadata.credit_multiplier,
|
||||
})
|
||||
.collect();
|
||||
let computer_use_provider_config = ResponseStream::resolve_provider_config(
|
||||
request_params.computer_use_model.as_str(),
|
||||
ctx,
|
||||
);
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
prepare_provider_run(
|
||||
base_provider_config,
|
||||
cli_provider_config,
|
||||
computer_use_provider_config,
|
||||
request_params,
|
||||
orchestration_models,
|
||||
)
|
||||
@@ -6587,6 +6631,7 @@ impl BlocklistAIController {
|
||||
let PreparedProviderRun {
|
||||
base_profile,
|
||||
cli_monitor_profile,
|
||||
computer_use_profile,
|
||||
tool_result_archive,
|
||||
messages_sent,
|
||||
persistence_offset,
|
||||
@@ -6612,6 +6657,12 @@ impl BlocklistAIController {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(profile) = computer_use_profile {
|
||||
if let Err(error) = coordinator.insert_profile(COMPUTER_USE_PROVIDER_PROFILE, profile) {
|
||||
self.fail_provider_startup(conversation_id, stream_id, error.to_string(), ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let cancellation_reason = self
|
||||
.active_provider_runs
|
||||
.get(&conversation_id)
|
||||
@@ -7481,7 +7532,7 @@ impl BlocklistAIController {
|
||||
let (acknowledgement, receiver) = oneshot::channel();
|
||||
projection_sender
|
||||
.send(ProviderDriveMessage::Projection {
|
||||
lifecycle,
|
||||
lifecycle: Box::new(lifecycle),
|
||||
latest_usage,
|
||||
tool_call_progress,
|
||||
events,
|
||||
@@ -7503,7 +7554,7 @@ impl BlocklistAIController {
|
||||
let (acknowledgement, receiver) = oneshot::channel();
|
||||
checkpoint_sender
|
||||
.send(ProviderDriveMessage::Checkpoint {
|
||||
checkpoint,
|
||||
checkpoint: Box::new(checkpoint),
|
||||
acknowledgement,
|
||||
})
|
||||
.await
|
||||
@@ -7519,7 +7570,10 @@ impl BlocklistAIController {
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
let _ = sender
|
||||
.send(ProviderDriveMessage::Blocked { run, result })
|
||||
.send(ProviderDriveMessage::Blocked {
|
||||
run: Box::new(run),
|
||||
result,
|
||||
})
|
||||
.await;
|
||||
},
|
||||
|_, _, _| {},
|
||||
@@ -7598,7 +7652,7 @@ impl BlocklistAIController {
|
||||
}
|
||||
};
|
||||
let mut should_refresh_status = progress_changed;
|
||||
if let Some(lifecycle) = lifecycle.as_ref() {
|
||||
if let Some(lifecycle) = lifecycle.as_ref().as_ref() {
|
||||
should_refresh_status |= {
|
||||
let slot = self
|
||||
.active_provider_runs
|
||||
@@ -7648,7 +7702,7 @@ impl BlocklistAIController {
|
||||
);
|
||||
});
|
||||
}
|
||||
let model_turn_requested = lifecycle.as_ref().is_some_and(|lifecycle| {
|
||||
let model_turn_requested = lifecycle.as_ref().as_ref().is_some_and(|lifecycle| {
|
||||
matches!(lifecycle.phase, ProviderLlmLifecyclePhase::Requested)
|
||||
});
|
||||
let projection_succeeded = result.is_ok();
|
||||
@@ -7681,7 +7735,7 @@ impl BlocklistAIController {
|
||||
} => {
|
||||
let result = match self.active_provider_runs.get_mut(&conversation_id) {
|
||||
Some(slot) if checkpoint.run.id() == &slot.run_id => {
|
||||
slot.checkpoint = Some(checkpoint);
|
||||
slot.checkpoint = Some(*checkpoint);
|
||||
self.persist_active_provider_run(conversation_id, ctx)
|
||||
}
|
||||
Some(_) => Err("provider checkpoint run identity did not match".to_string()),
|
||||
@@ -7690,7 +7744,7 @@ impl BlocklistAIController {
|
||||
let _ = acknowledgement.send(result);
|
||||
}
|
||||
ProviderDriveMessage::Blocked { run, result } => {
|
||||
self.handle_provider_run_blocked(conversation_id, run, result, ctx);
|
||||
self.handle_provider_run_blocked(conversation_id, *run, result, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8263,10 +8317,44 @@ impl BlocklistAIController {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Screenshot results belong to the next model turn. Select its runtime only after
|
||||
// the complete tool batch is committed, so a restart sees the same decision.
|
||||
let next_tool_profile = match run.coordinator.run().state() {
|
||||
ProviderRunState::AwaitingTools { batch } => {
|
||||
let used_computer = batch.calls.iter().any(|pending| {
|
||||
matches!(
|
||||
pending.call.name.as_str(),
|
||||
"request_computer_use" | "use_computer"
|
||||
)
|
||||
});
|
||||
used_computer.then_some(COMPUTER_USE_PROVIDER_PROFILE)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut should_resume = false;
|
||||
let should_drive = match run.coordinator.apply_tool_lifecycle(execution_ref, event) {
|
||||
Ok(ProviderToolLifecycleOutcome::Pending) => false,
|
||||
Ok(ProviderToolLifecycleOutcome::BatchCommitted) => {
|
||||
if let Some(profile) = next_tool_profile.filter(|_| {
|
||||
run.coordinator
|
||||
.profile_request(COMPUTER_USE_PROVIDER_PROFILE)
|
||||
.is_some()
|
||||
}) {
|
||||
if let Err(error) = run.coordinator.run_mut().set_ready_profile(profile) {
|
||||
self.fail_active_provider_run(conversation_id, error.to_string(), ctx);
|
||||
return;
|
||||
}
|
||||
} else if run.coordinator.run().profile().as_str() == COMPUTER_USE_PROVIDER_PROFILE
|
||||
{
|
||||
if let Err(error) = run
|
||||
.coordinator
|
||||
.run_mut()
|
||||
.set_ready_profile(BASE_PROVIDER_PROFILE)
|
||||
{
|
||||
self.fail_active_provider_run(conversation_id, error.to_string(), ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let work_id = execution_ref.work_id();
|
||||
should_resume = record_provider_batch_signal(
|
||||
&mut slot.committed_provider_batch,
|
||||
|
||||
@@ -260,6 +260,7 @@ fn provider_snapshot(conversation_id: AIConversationId) -> super::ActiveProvider
|
||||
),
|
||||
base_request: TurnRequest::new("provider-model", messages),
|
||||
cli_monitor_request: None,
|
||||
computer_use_request: None,
|
||||
response_config: crate::ai::runtime::RuntimeResponseConfig {
|
||||
task_id: task_id.to_string(),
|
||||
conversation_id: conversation_id.to_string(),
|
||||
@@ -1042,6 +1043,7 @@ fn cancelling_provider_startup_keeps_slot_and_durable_cancellation() {
|
||||
run: snapshot.run.clone(),
|
||||
base_request: snapshot.base_request.clone(),
|
||||
cli_monitor_request: snapshot.cli_monitor_request.clone(),
|
||||
computer_use_request: snapshot.computer_use_request.clone(),
|
||||
response_config: snapshot.response_config.clone(),
|
||||
action_context: snapshot.action_context.clone(),
|
||||
persistence_offset: snapshot.persistence_offset,
|
||||
@@ -1157,6 +1159,7 @@ fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup()
|
||||
run: old_snapshot.run.clone(),
|
||||
base_request: old_snapshot.base_request.clone(),
|
||||
cli_monitor_request: old_snapshot.cli_monitor_request.clone(),
|
||||
computer_use_request: old_snapshot.computer_use_request.clone(),
|
||||
response_config: old_snapshot.response_config.clone(),
|
||||
action_context: old_snapshot.action_context.clone(),
|
||||
persistence_offset: old_snapshot.persistence_offset,
|
||||
|
||||
@@ -1250,13 +1250,20 @@ impl RequestedCommandView {
|
||||
self.block_model.as_ref().conversation(app).is_some_and(
|
||||
|conversation| conversation.status().is_error(),
|
||||
);
|
||||
|
||||
if is_errored {
|
||||
AGENT_ERRORED_COMMAND_MESSAGE.into()
|
||||
let (status, short_status) = if is_errored {
|
||||
(AGENT_ERRORED_COMMAND_MESSAGE, "Agent needs help")
|
||||
} else if *is_blocked {
|
||||
AGENT_NEEDS_INPUT_MESSAGE.into()
|
||||
(AGENT_NEEDS_INPUT_MESSAGE, "Needs input")
|
||||
} else {
|
||||
MONITORING_COMMAND_MESSAGE.into()
|
||||
(MONITORING_COMMAND_MESSAGE, "Agent monitoring")
|
||||
};
|
||||
let command = self.get_header_title_text();
|
||||
if command.trim().is_empty() {
|
||||
status.into()
|
||||
} else {
|
||||
font_override = Some(appearance.monospace_font_family());
|
||||
badge = Some(short_status.to_string());
|
||||
command.into()
|
||||
}
|
||||
}
|
||||
LongRunningCommandControlState::User { reason } => {
|
||||
|
||||
@@ -705,7 +705,7 @@ impl BlocklistAIPermissions {
|
||||
}
|
||||
|
||||
match self.get_read_files_setting(ctx, terminal_view_id) {
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => {
|
||||
ActionPermission::Unknown => {
|
||||
// For now, we always read files. We don't ask the user for permission.
|
||||
FileReadPermission::Allowed(FileReadPermissionAllowedReason::AgentDecided)
|
||||
}
|
||||
@@ -838,9 +838,7 @@ impl BlocklistAIPermissions {
|
||||
.is_some_and(|uid| self.get_mcp_denylist(ctx, terminal_view_id).contains(&uid));
|
||||
|
||||
match self.get_mcp_permissions_setting(ctx, terminal_view_id) {
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => {
|
||||
allowlisted && !denylisted
|
||||
}
|
||||
ActionPermission::Unknown => allowlisted && !denylisted,
|
||||
ActionPermission::AlwaysAllow => !denylisted,
|
||||
ActionPermission::AlwaysAsk | ActionPermission::GalaxyDecides => {
|
||||
allowlisted && !denylisted
|
||||
@@ -855,7 +853,7 @@ impl BlocklistAIPermissions {
|
||||
ctx: &AppContext,
|
||||
) -> FileWritePermission {
|
||||
match self.get_apply_code_diffs_setting(ctx, terminal_view_id) {
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => {
|
||||
ActionPermission::Unknown => {
|
||||
FileWritePermission::Denied(FileWritePermissionDeniedReason::AgentDecided)
|
||||
}
|
||||
ActionPermission::AlwaysAllow => FileWritePermission::Allowed(
|
||||
@@ -915,7 +913,7 @@ impl BlocklistAIPermissions {
|
||||
}
|
||||
|
||||
match self.get_execute_commands_setting(ctx, terminal_view_id) {
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => {
|
||||
ActionPermission::Unknown => {
|
||||
if FeatureFlag::AgentDecidesCommandExecution.is_enabled() && is_risky == Some(false)
|
||||
{
|
||||
return CommandExecutionPermission::Allowed(
|
||||
|
||||
@@ -337,11 +337,11 @@ fn test_can_write_files() {
|
||||
..
|
||||
} = initialize_permissions_test(&mut app);
|
||||
|
||||
// Test AgentDecides setting
|
||||
// Test GalaxyDecides setting before the independent review gate.
|
||||
profile_model.update(&mut app, |model, ctx| {
|
||||
model.set_apply_code_diffs(
|
||||
*model.active_profile(Some(terminal_view_id), ctx).id(),
|
||||
&ActionPermission::AgentDecides,
|
||||
&ActionPermission::GalaxyDecides,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
@@ -352,9 +352,9 @@ fn test_can_write_files() {
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
FileWritePermission::Denied(FileWritePermissionDeniedReason::AgentDecided)
|
||||
FileWritePermission::Denied(FileWritePermissionDeniedReason::AlwaysAskEnabled)
|
||||
),
|
||||
"not allowed because AgentDecides right now just means ask"
|
||||
"GalaxyDecides requires the independent action review"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1223,7 +1223,7 @@ fn test_can_use_mcp_server_always_ask_denylist_overrides_allowlist() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_use_mcp_server_agent_decides() {
|
||||
fn test_can_use_mcp_server_galaxy_decides() {
|
||||
App::test((), |mut app| async move {
|
||||
let PermissionsTestState {
|
||||
convo_id,
|
||||
@@ -1239,7 +1239,7 @@ fn test_can_use_mcp_server_agent_decides() {
|
||||
profile_model.update(&mut app, |model, ctx| {
|
||||
model.set_mcp_permissions(
|
||||
*model.active_profile(Some(terminal_view_id), ctx).id(),
|
||||
&ActionPermission::AgentDecides,
|
||||
&ActionPermission::GalaxyDecides,
|
||||
ctx,
|
||||
);
|
||||
model.add_to_mcp_allowlist(
|
||||
@@ -1269,7 +1269,7 @@ fn test_can_use_mcp_server_agent_decides() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_use_mcp_server_agent_decides_denylist_overrides_allowlist() {
|
||||
fn test_can_use_mcp_server_galaxy_decides_denylist_overrides_allowlist() {
|
||||
App::test((), |mut app| async move {
|
||||
let PermissionsTestState {
|
||||
convo_id,
|
||||
@@ -1284,7 +1284,7 @@ fn test_can_use_mcp_server_agent_decides_denylist_overrides_allowlist() {
|
||||
profile_model.update(&mut app, |model, ctx| {
|
||||
model.set_mcp_permissions(
|
||||
*model.active_profile(Some(terminal_view_id), ctx).id(),
|
||||
&ActionPermission::AgentDecides,
|
||||
&ActionPermission::GalaxyDecides,
|
||||
ctx,
|
||||
);
|
||||
model.add_to_mcp_allowlist(
|
||||
|
||||
Reference in New Issue
Block a user