Files
galaxy/app/src/ai/blocklist/block/cli_controller.rs
T

624 lines
25 KiB
Rust

use std::{collections::HashMap, sync::Arc};
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
use instant::Instant;
use parking_lot::FairMutex;
use serde::{Deserialize, Serialize};
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::{
ai::{
agent::{
conversation::AIConversationId, task::TaskId, AIAgentActionId, AIAgentActionResultType,
AIAgentContext, CancellationReason, ReadShellCommandOutputResult,
RequestCommandOutputResult, TransferShellCommandControlToUserResult,
WriteToLongRunningShellCommandResult,
},
blocklist::{
agent_view::{AgentViewController, AgentViewEntryOrigin},
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIHistoryEvent,
},
},
terminal::{
model::block::BlockId,
model_events::{ModelEvent, ModelEventDispatcher},
TerminalModel,
},
BlocklistAIHistoryModel,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum UserTakeOverReason {
Manual,
Stop,
/// The agent explicitly transferred control to the user via the
/// TransferShellCommandControlToUser tool call.
TransferFromAgent {
/// The reason the agent gave for transferring control.
reason: String,
},
}
#[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState {
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
}
impl UserTakeOverReason {
pub fn is_stop(&self) -> bool {
matches!(self, Self::Stop)
}
pub fn is_transfer_from_agent(&self) -> bool {
matches!(self, Self::TransferFromAgent { .. })
}
pub fn transfer_reason(&self) -> Option<&str> {
match self {
Self::TransferFromAgent { reason } => Some(reason.as_str()),
_ => None,
}
}
}
/// Represents which party is in control of the active long running command.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LongRunningCommandControlState {
/// The agent is in control.
///
/// When the agent has control, the user cannot submit input to the command.
Agent {
/// `true` if the agent is blocked on approval from the user for submitting input.
is_blocked: bool,
/// `true` if agent responses should be hidden in the UI.
should_hide_responses: bool,
},
/// The user is in control.
User { reason: UserTakeOverReason },
}
impl LongRunningCommandControlState {
pub fn is_agent_in_control(&self) -> bool {
matches!(self, Self::Agent { .. })
}
pub fn is_agent_blocked(&self) -> bool {
matches!(
self,
Self::Agent {
is_blocked: true,
..
}
)
}
pub fn is_user_in_control(&self) -> bool {
matches!(self, Self::User { .. })
}
pub fn should_hide_responses(&self) -> bool {
matches!(
self,
Self::Agent {
should_hide_responses: true,
..
}
)
}
pub fn user_take_over_reason(&self) -> Option<&UserTakeOverReason> {
match &self {
LongRunningCommandControlState::Agent { .. } => None,
LongRunningCommandControlState::User { reason } => Some(reason),
}
}
}
/// Responsible for managing 'control' (e.g. write permissions) for the active long running
/// agent-requested command.
///
/// Control state is canonically stored on the relevant command `Block` owned by terminal model,
/// but wrapping update APIs in this controller ensures consistent update semantics and makes
/// control state updates subscribable.
pub struct CLISubagentController {
controller: ModelHandle<BlocklistAIController>,
action_model: ModelHandle<BlocklistAIActionModel>,
agent_view_controller: Option<ModelHandle<AgentViewController>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
// Active or recently-active CLI subagent state, keyed by the associated block.
active_subagents_by_block: HashMap<BlockId, ActiveCLISubagentState>,
}
impl CLISubagentController {
pub fn new(
controller: &ModelHandle<BlocklistAIController>,
action_model: &ModelHandle<BlocklistAIActionModel>,
agent_view_controller: Option<ModelHandle<AgentViewController>>,
terminal_model: Arc<FairMutex<TerminalModel>>,
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_model_event);
ctx.subscribe_to_model(action_model, |me, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(true);
let action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: action_id,
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::ExecutingAction(..) => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: action_id,
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
let snapshot_block_id = me
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.and_then(|result| snapshot_block_id_for_action_result(&result.result))
.cloned();
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned();
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: active_block.id().clone(),
requested_command_action_id: action_id,
agent_has_control: active_block.is_agent_in_control(),
});
// Updates the last snapshot timestamp for the active block after the agent has read the block output.
if let Some(snapshot_block_id) = snapshot_block_id {
me.active_subagents_by_block
.entry(snapshot_block_id.clone())
.or_default()
.last_snapshot_at = Some(Instant::now());
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
}
_ => (),
});
ctx.subscribe_to_model(model_event_dispatcher, |me, event, ctx| {
if let ModelEvent::BlockCompleted(block_completed_event) = event {
let terminal_model = me.terminal_model.lock();
let Some(block) = terminal_model
.block_list()
.block_with_id(&block_completed_event.block_id)
else {
return;
};
let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned();
let was_agent_tagged_in = block.interaction_mode().is_agent_tagged_in();
let has_agent_metadata = block.agent_interaction_metadata().is_some();
drop(terminal_model);
let removed_subagent_state = me.active_subagents_by_block.remove(&block_id);
if removed_subagent_state
.as_ref()
.is_some_and(|state| state.last_snapshot_at.is_some())
{
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if removed_subagent_state
.as_ref()
.is_some_and(|state| state.task_id.is_some())
{
let is_inline_agent_view =
me.agent_view_controller.as_ref().is_some_and(|controller| {
controller.read(ctx, |controller, _| controller.is_inline())
});
if is_inline_agent_view {
// Mark conversation as successfully completed BEFORE exiting agent view.
// The command finished naturally, so this is a successful completion.
if let Some(conversation_id) = conversation_id {
me.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::OptimisticCLISubagentCompletion,
ctx,
);
});
}
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id,
conversation_id,
initial_requested_command_action_id: requested_command_action_id,
});
}
// Exit inline agent view if agent was tagged in or had metadata (was in control).
if let Some(agent_view_controller) = &me.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
if controller.is_inline() && (was_agent_tagged_in || has_agent_metadata) {
controller.exit_agent_view(ctx);
}
});
}
}
});
Self {
controller: controller.clone(),
action_model: action_model.clone(),
agent_view_controller,
terminal_model,
terminal_view_id,
active_subagents_by_block: HashMap::new(),
}
}
pub fn is_agent_in_control(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.active_block()
.is_agent_in_control()
}
pub(crate) fn is_agent_in_control_or_tagged_in(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.active_block()
.is_agent_in_control_or_tagged_in()
}
pub fn last_snapshot_at(&self, block_id: &BlockId) -> Option<Instant> {
self.active_subagents_by_block
.get(block_id)
.and_then(|state| state.last_snapshot_at)
}
/// Force the currently in-flight poll for the given long-running command block to
/// resolve immediately with a fresh snapshot, bypassing the agent-set timeout.
/// Backs the `Check now` affordance surfaced next to the `Last seen by agent ...`
/// indicator in the warping footer.
pub fn request_force_refresh(&self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let executor_handle = self.action_model.as_ref(ctx).shell_command_executor(ctx);
let block_id = block_id.clone();
executor_handle.update(ctx, move |executor, _| {
executor.force_refresh_block(&block_id);
});
}
pub fn switch_control_to_user(&self, reason: UserTakeOverReason, ctx: &mut ModelContext<Self>) {
let should_cancel_conversation = !reason.is_transfer_from_agent();
let mut terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
let block_id = active_block.id().clone();
if let Err(e) = active_block.take_over_control_for_user(reason) {
log::error!("Failed to take control for user: {e:?}");
return;
}
let action_id = active_block.requested_command_action_id().cloned();
let conversation_id = active_block.ai_conversation_id();
let agent_has_control = active_block.is_agent_in_control();
// Conversation cancellation potentially takes a lock on terminal model if the
// cancelled action is a shell command action, so we have to drop the terminal
// model lock before actually cancelling the conversation.
drop(terminal_model);
// Only cancel conversation if user manually took control (not when agent transfers control).
if should_cancel_conversation {
if let Some(conversation_id) = conversation_id {
self.controller.update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
}
}
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id,
agent_has_control,
});
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentControlStateChanged {
conversation_id,
block_id,
control_state: CLISubagentControlState::UserInControl,
},
ctx
);
}
pub fn handoff_active_command_control_to_agent(&self, ctx: &mut ModelContext<Self>) {
let mut terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
let conversation_id = active_block.ai_conversation_id();
let block_id = active_block.id().clone();
// Check if control was transferred from agent before handoff.
let was_transfer_from_agent = active_block
.long_running_control_state()
.and_then(|state| state.user_take_over_reason())
.is_some_and(|reason| reason.is_transfer_from_agent());
if let Err(e) = active_block.handoff_control_to_agent() {
log::error!("Failed to handoff control to agent: {e:?}");
return;
}
let action_id = active_block.requested_command_action_id().cloned();
let agent_has_control = active_block.is_agent_in_control();
drop(terminal_model);
if let Some(agent_view_controller) = &self.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
if !controller.is_inline() {
if let Err(e) = controller.try_enter_inline_agent_view(
conversation_id,
AgentViewEntryOrigin::LongRunningCommand,
ctx,
) {
log::error!("Failed to enter inline agent view for LRC handoff: {e}");
}
}
});
}
// Trigger an auto-resume of the conversation when handing control to the agent.
if let Some(conversation_id) = conversation_id {
let is_viewing_shared_session = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.is_viewing_shared_session());
if !is_viewing_shared_session {
let resume_context = {
let terminal_model = self.terminal_model.lock();
block_context_from_terminal_model(&terminal_model, &block_id, false)
.map(Box::new)
.map(AIAgentContext::Block)
.into_iter()
.collect()
};
self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation(
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
});
}
}
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id,
agent_has_control,
});
// Emit a special event if control was transferred from agent, so the executor can be notified.
if was_transfer_from_agent {
ctx.emit(CLISubagentEvent::ControlHandedBackAfterTransfer);
}
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentControlStateChanged {
conversation_id,
block_id,
control_state: CLISubagentControlState::AgentInControl,
},
ctx
);
}
pub fn toggle_hide_responses(&self, ctx: &mut ModelContext<Self>) {
let mut terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
if active_block.toggle_subagent_response_visibility() {
let conversation_id = active_block.ai_conversation_id();
let block_id = active_block.id().clone();
let is_hidden = active_block.should_hide_responses();
ctx.emit(CLISubagentEvent::ToggledHideResponses);
if let Some(conversation_id) = conversation_id {
send_telemetry_from_ctx!(
TelemetryEvent::CLISubagentResponsesToggled {
conversation_id,
block_id,
is_hidden,
},
ctx
);
}
}
}
fn handle_history_model_event(
&mut self,
event: &BlocklistAIHistoryEvent,
ctx: &mut ModelContext<Self>,
) {
if event
.terminal_view_id()
.is_some_and(|id| id != self.terminal_view_id)
{
return;
}
match event {
BlocklistAIHistoryEvent::CreatedSubtask {
task_id,
conversation_id,
..
} => {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let Some(cli_subagent_block_id) = history_model
.as_ref(ctx)
.conversation(conversation_id)
.and_then(|c| c.get_task(task_id))
.and_then(|task| task.cli_subagent_block_id())
else {
return;
};
let mut terminal_model = self.terminal_model.lock();
let Some(block) = terminal_model
.block_list_mut()
.mut_block_from_id(&cli_subagent_block_id)
else {
return;
};
let block_id = block.id().clone();
if let Err(e) = block.set_agent_interaction_mode_for_agent_monitored_command(
task_id,
*conversation_id,
) {
log::error!("Could not update interaction mode to agent-monitored: {e:?}",);
return;
};
let action_id = block.requested_command_action_id().cloned();
let agent_has_control = block.is_agent_in_control();
drop(terminal_model);
// When the CLI subagent is first created for a long running command,
// the agent now has control. Emit an UpdatedControl event so that
// shared-session state can reflect this initial control state.
ctx.emit(CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: action_id.clone(),
agent_has_control,
});
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.task_id = Some(task_id.clone());
ctx.emit(CLISubagentEvent::SpawnedSubagent {
task_id: task_id.clone(),
conversation_id: *conversation_id,
block_id: block_id.clone(),
initial_requested_command_action_id: action_id,
});
}
BlocklistAIHistoryEvent::UpgradedTask {
optimistic_id: old_id,
server_id: new_id,
..
} => {
let block_id =
self.active_subagents_by_block
.iter()
.find_map(|(block_id, state)| {
(state.task_id.as_ref() == Some(old_id)).then_some(block_id.clone())
});
if let Some(block_id) = block_id {
let mut terminal_model = self.terminal_model.lock();
if let Some(block) =
terminal_model.block_list_mut().mut_block_from_id(&block_id)
{
match block.upgrade_cli_subagent_task_id(new_id.clone()) {
Ok(()) => {
if let Some(state) =
self.active_subagents_by_block.get_mut(&block_id)
{
state.task_id = Some(new_id.clone());
}
}
Err(e) => {
log::error!(
"Tried to upgrade CLISubagent task ID for non-existent block: {e:?}"
);
}
}
}
}
}
_ => (),
}
}
}
#[derive(Debug, Clone)]
pub enum CLISubagentEvent {
// Emitted when a CLI subagent is spawned for a running command block.
SpawnedSubagent {
task_id: TaskId,
block_id: BlockId,
conversation_id: AIConversationId,
/// The ID of the requested command for which this subagent was spawned, if any.
///
/// None if the subagent was spawned by entering agent mode during a user-executed command,
/// rather than a requested command.
initial_requested_command_action_id: Option<AIAgentActionId>,
},
// Emitted when a CLI subagent's execution ends.
FinishedSubagent {
block_id: BlockId,
conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
},
UpdatedControl {
block_id: BlockId,
requested_command_action_id: Option<AIAgentActionId>,
agent_has_control: bool,
},
UpdatedLastSnapshot,
ToggledHideResponses,
/// Emitted when the user hands control back to the agent after a
/// TransferShellCommandControlToUser action.
ControlHandedBackAfterTransfer,
}
impl Entity for CLISubagentController {
type Event = CLISubagentEvent;
}
fn snapshot_block_id_for_action_result(result: &AIAgentActionResultType) -> Option<&BlockId> {
// Enumerates all possible action result types that read a command output.
match result {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Snapshot { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::LongRunningCommandSnapshot { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Snapshot { block_id, .. },
) => Some(block_id),
_ => None,
}
}