Complete agent monitoring and Galaxy Control integration

- expose command-monitor conversations and preserve visible agent transcripts
- add bounded polling and a dedicated shell interrupt tool
- improve direct-provider images, skills, tool history, and usage handling
- package and brand Galaxy Control across releases, installers, persistence, and docs
This commit is contained in:
2026-07-29 15:04:58 -05:00
parent 100f1eff1c
commit dbfa8bcd48
172 changed files with 6357 additions and 3825 deletions
+391 -107
View File
@@ -10,15 +10,17 @@ use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, AIAgentContext, CancellationReason,
ReadShellCommandOutputResult, RequestCommandOutputResult,
ReadShellCommandOutputResult, RequestCommandOutputResult, RunningCommand,
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController, BlocklistAIHistoryEvent,
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
};
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
use crate::terminal::event::BlockType;
use crate::terminal::model::block::BlockId;
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::TerminalModel;
@@ -38,8 +40,19 @@ pub enum UserTakeOverReason {
#[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState {
initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
completion: Option<PendingCommandCompletion>,
}
#[derive(Debug, Clone)]
struct PendingCommandCompletion {
conversation_id: AIConversationId,
initial_requested_command_action_id: Option<AIAgentActionId>,
prompt: String,
completed_command: RunningCommand,
final_turn_started: bool,
}
impl UserTakeOverReason {
@@ -140,6 +153,15 @@ impl CLISubagentController {
) -> Self {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_model_event);
ctx.subscribe_to_model(controller, |me, _, event, ctx| {
let BlocklistAIControllerEvent::FinishedReceivingOutput {
conversation_id, ..
} = event
else {
return;
};
me.advance_completed_subagents(*conversation_id, ctx);
});
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
@@ -166,21 +188,39 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
let snapshot_block_id = me
BlocklistAIActionEvent::FinishedAction {
action_id: finished_action_id,
..
} => {
let action_result = me
.action_model
.as_ref(ctx)
.get_action_result(action_id)
.get_action_result(finished_action_id);
let initial_command_finished_without_snapshot =
action_result.is_some_and(|result| {
matches!(
&result.result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::Denylisted { .. }
)
)
});
let snapshot_block_id = action_result
.and_then(|result| snapshot_block_id_for_action_result(&result.result))
.cloned();
let command_finished_block_id = action_result
.and_then(|result| command_finished_block_id(&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();
let active_command_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,
requested_command_action_id: active_command_action_id,
agent_has_control: active_block.is_agent_in_control(),
});
@@ -192,6 +232,22 @@ impl CLISubagentController {
.last_snapshot_at = Some(Instant::now());
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if initial_command_finished_without_snapshot {
me.active_subagents_by_block.retain(|_, state| {
state.task_id.is_some()
|| state.initial_requested_command_action_id.as_ref()
!= Some(finished_action_id)
});
}
if let Some(block_id) = command_finished_block_id {
if let Some(completion) = me
.active_subagents_by_block
.get_mut(&block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
}
}
_ => (),
});
@@ -209,55 +265,65 @@ impl CLISubagentController {
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();
let completion = match (&block_completed_event.block_type, conversation_id) {
(BlockType::User(completed), Some(conversation_id)) => {
let command = if completed.command_with_obfuscated_secrets.is_empty() {
completed.command.clone()
} else {
completed.command_with_obfuscated_secrets.clone()
};
let output = completed
.output_truncated_with_obfuscated_secrets
.clone();
let exit_code = completed.serialized_block.exit_code.value();
Some(PendingCommandCompletion {
conversation_id,
initial_requested_command_action_id: requested_command_action_id
.clone(),
prompt: format!(
"The monitored command has finished with exit code {exit_code}. \
Give the user a concise final assessment grounded in the final \
output below. Do not call another shell tool or restart the \
command.\n\nCommand:\n```sh\n{command}\n```\n\nFinal output:\n```text\n{output}\n```"
),
completed_command: RunningCommand {
command,
block_id: block_id.clone(),
grid_contents: output,
cursor: String::new(),
requested_command_id: requested_command_action_id.clone(),
is_alt_screen_active: false,
},
final_turn_started: false,
})
}
(
BlockType::BootstrapHidden
| BlockType::BootstrapVisible(_)
| BlockType::Restored
| BlockType::InBandCommand
| BlockType::Background(_)
| BlockType::Static,
_,
)
| (BlockType::User(_), None) => None,
};
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())
{
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
return;
};
if subagent_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::CommandFinishedDuringInlineAgentView,
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);
}
});
subagent_state.completion = completion;
if subagent_state.completion.is_none() {
log::warn!(
"CLI monitor block {block_id:?} completed without final command metadata"
);
return;
}
me.advance_completed_subagent(&block_id, ctx);
}
});
@@ -271,6 +337,112 @@ impl CLISubagentController {
}
}
fn advance_completed_subagents(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let block_ids = self
.active_subagents_by_block
.iter()
.filter_map(|(block_id, state)| {
state
.completion
.as_ref()
.is_some_and(|completion| completion.conversation_id == conversation_id)
.then_some(block_id.clone())
})
.collect::<Vec<_>>();
for block_id in block_ids {
self.advance_completed_subagent(&block_id, ctx);
}
}
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some((task_id, completion)) = self
.active_subagents_by_block
.get(block_id)
.and_then(|state| Some((state.task_id.clone()?, state.completion.as_ref()?.clone())))
else {
return;
};
let has_active_stream = self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(completion.conversation_id, ctx);
let has_unfinished_action = self
.action_model
.as_ref(ctx)
.has_unfinished_actions_for_conversation(completion.conversation_id);
if has_active_stream || has_unfinished_action {
return;
}
if completion.final_turn_started {
self.finish_completed_subagent(block_id, ctx);
return;
}
let sent = self.controller.update(ctx, |controller, ctx| {
controller.send_command_completion_assessment(
completion.conversation_id,
task_id,
completion.prompt,
completion.completed_command,
ctx,
)
});
if sent {
if let Some(completion) = self
.active_subagents_by_block
.get_mut(block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
}
}
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let Some(completion) = state.completion else {
return;
};
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model.deactivate_cli_subagent_task_for_conversation(
block_id,
completion.conversation_id,
)
});
if let Err(error) = deactivate_result {
log::error!(
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
);
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id: block_id.clone(),
conversation_id: Some(completion.conversation_id),
initial_requested_command_action_id: completion.initial_requested_command_action_id,
});
if let Some(agent_view_controller) = &self.agent_view_controller {
agent_view_controller.update(ctx, |controller, ctx| {
let is_this_inline_conversation = controller.is_inline()
&& controller.agent_view_state().active_conversation_id()
== Some(completion.conversation_id);
if is_this_inline_conversation {
controller.exit_agent_view(ctx);
}
});
}
}
pub fn is_agent_in_control(&self) -> bool {
let terminal_model = self.terminal_model.lock();
terminal_model
@@ -293,16 +465,34 @@ impl CLISubagentController {
.and_then(|state| state.last_snapshot_at)
}
/// Begins tracking an agent-requested command before its shell event is dispatched.
///
/// The placeholder lets command completion and action-result events arrive in either order
/// without losing the completion that a subsequently-created CLI monitor needs.
pub fn track_requested_command(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) {
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_action_id = Some(action_id.clone());
}
/// 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>) {
/// indicator in the command status footer. Returns whether a matching poll was refreshed.
pub fn request_force_refresh(
&mut self,
block_id: &BlockId,
ctx: &mut ModelContext<Self>,
) -> bool {
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);
});
let refreshed =
executor_handle.update(ctx, |executor, _| executor.force_refresh_block(&block_id));
if refreshed {
self.active_subagents_by_block.entry(block_id).or_default();
}
refreshed
}
pub fn switch_control_to_user(&self, reason: UserTakeOverReason, ctx: &mut ModelContext<Self>) {
@@ -475,6 +665,81 @@ impl CLISubagentController {
}
}
fn spawn_cli_subagent_for_task_if_ready(
&mut self,
conversation_id: AIConversationId,
task_id: &TaskId,
ctx: &mut ModelContext<Self>,
) {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let Some(conversation) = history_model.as_ref(ctx).conversation(&conversation_id) else {
return;
};
let Some(task) = conversation.get_task(task_id) else {
return;
};
let Some(cli_subagent_block_id) = task.cli_subagent_block_id() else {
return;
};
// The direct-provider action-result path creates the optimistic task before appending its
// first exchange. Depending on event delivery order, CreatedSubtask can therefore arrive
// before the view model is constructible. AppendedExchange retries this same idempotent
// path.
if task.last_exchange().is_none()
|| conversation
.is_subagent_task_finished(task_id)
.unwrap_or(true)
|| self
.active_subagents_by_block
.get(&cli_subagent_block_id)
.and_then(|state| state.task_id.as_ref())
== Some(task_id)
{
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,
block_id,
initial_requested_command_action_id: action_id,
});
self.advance_completed_subagent(&cli_subagent_block_id, ctx);
}
fn handle_history_model_event(
&mut self,
_: ModelHandle<BlocklistAIHistoryModel>,
@@ -492,57 +757,12 @@ impl CLISubagentController {
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::AppendedExchange {
task_id,
conversation_id,
..
} => self.spawn_cli_subagent_for_task_if_ready(*conversation_id, task_id, ctx),
BlocklistAIHistoryEvent::UpgradedTask {
optimistic_id: old_id,
server_id: new_id,
@@ -635,3 +855,67 @@ fn snapshot_block_id_for_action_result(result: &AIAgentActionResultType) -> Opti
_ => None,
}
}
fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockId> {
match result {
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
block_id,
..
})
| AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::CommandFinished { block_id, .. },
)
| AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::CommandFinished { block_id, .. },
)
| AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::CommandFinished { block_id, .. },
) => Some(block_id),
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::Denylisted { .. },
)
| AIAgentActionResultType::WriteToLongRunningShellCommand(
WriteToLongRunningShellCommandResult::Snapshot { .. }
| WriteToLongRunningShellCommandResult::Cancelled
| WriteToLongRunningShellCommandResult::Error(_),
)
| AIAgentActionResultType::ReadShellCommandOutput(
ReadShellCommandOutputResult::LongRunningCommandSnapshot { .. }
| ReadShellCommandOutputResult::Cancelled
| ReadShellCommandOutputResult::Error(_),
)
| AIAgentActionResultType::TransferShellCommandControlToUser(
TransferShellCommandControlToUserResult::Snapshot { .. }
| TransferShellCommandControlToUserResult::Cancelled
| TransferShellCommandControlToUserResult::Error(_),
)
| AIAgentActionResultType::RequestFileEdits(_)
| AIAgentActionResultType::ReadFiles(_)
| AIAgentActionResultType::UploadArtifact(_)
| AIAgentActionResultType::SearchCodebase(_)
| AIAgentActionResultType::Grep(_)
| AIAgentActionResultType::FileGlob(_)
| AIAgentActionResultType::FileGlobV2(_)
| AIAgentActionResultType::ReadMCPResource(_)
| AIAgentActionResultType::CallMCPTool(_)
| AIAgentActionResultType::ReadSkill(_)
| AIAgentActionResultType::SuggestNewConversation(_)
| AIAgentActionResultType::SuggestPrompt(_)
| AIAgentActionResultType::OpenCodeReview
| AIAgentActionResultType::InitProject
| AIAgentActionResultType::ReadDocuments(_)
| AIAgentActionResultType::EditDocuments(_)
| AIAgentActionResultType::CreateDocuments(_)
| AIAgentActionResultType::UseComputer(_)
| AIAgentActionResultType::InsertReviewComments(_)
| AIAgentActionResultType::RequestComputerUse(_)
| AIAgentActionResultType::FetchConversation(_)
| AIAgentActionResultType::StartAgent(_)
| AIAgentActionResultType::SendMessageToAgent(_)
| AIAgentActionResultType::AskUserQuestion(_)
| AIAgentActionResultType::RunAgents(_)
| AIAgentActionResultType::WaitForEvents(_) => None,
}
}