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
+183 -40
View File
@@ -49,8 +49,8 @@ use crate::ai::agent::{
AIAgentContext, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, AIIdentifiers,
CancellationOutcome, CancellationReason, DocumentContentAttachmentSource, EntrypointType,
FileContext, FinishedAIAgentOutput, PassiveSuggestionResultType, PassiveSuggestionTrigger,
PassiveSuggestionTriggerType, RenderableAIError, RequestCost, RequestMetadata, RunningCommand,
StaticQueryType, TransientNetworkErrorKind, UserQueryMode,
PassiveSuggestionTriggerType, RenderableAIError, RequestCommandOutputResult, RequestCost,
RequestMetadata, RunningCommand, StaticQueryType, TransientNetworkErrorKind, UserQueryMode,
};
use crate::ai::agent_events::AgentMessageEventMetadata;
#[cfg(not(target_family = "wasm"))]
@@ -263,6 +263,12 @@ pub struct RequestInput {
pub supported_tools_override: Option<Vec<ToolType>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RunningCommandDetection {
Detect,
Skip,
}
impl RequestInput {
fn for_task(
inputs: Vec<AIAgentInput>,
@@ -808,7 +814,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
vec![],
ctx,
);
@@ -1134,7 +1139,7 @@ impl BlocklistAIController {
query,
conversation_id,
None,
false,
RunningCommandDetection::Detect,
HashMap::new(),
EntrypointType::AgentInitiated,
/*is_queued_prompt*/ false,
@@ -1143,6 +1148,70 @@ impl BlocklistAIController {
);
}
/// Sends one non-preemptive final assessment to a completed CLI-monitor task.
///
/// This deliberately bypasses `send_query`: command completion must not cancel
/// another conversation, drain unrelated action results, or replace a request
/// that is still delivering the command's final tool result.
pub fn send_command_completion_assessment(
&mut self,
conversation_id: AIConversationId,
task_id: TaskId,
query: String,
completed_command: RunningCommand,
ctx: &mut ModelContext<Self>,
) -> bool {
if self
.in_flight_response_streams
.has_active_stream_for_conversation(conversation_id, ctx)
|| self
.action_model
.as_ref(ctx)
.has_unfinished_actions_for_conversation(conversation_id)
{
return false;
}
let context = input_context_for_request(
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
vec![],
ctx,
);
let request_input = RequestInput::for_task(
vec![AIAgentInput::UserQuery {
query,
context,
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Normal,
running_command: Some(completed_command),
intended_agent: None,
}],
task_id,
&self.active_session,
self.get_current_response_initiator(),
conversation_id,
self.terminal_surface_id,
ctx,
)
.with_supported_tools(vec![]);
self.send_request_input(
request_input,
Some(RequestMetadata {
is_autodetected_user_query: false,
entrypoint: EntrypointType::AgentInitiated,
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ false,
/*is_queued_prompt*/ false,
ctx,
)
.is_ok()
}
/// Sends the given user query to the AI model.
pub fn send_user_query_in_conversation(
&mut self,
@@ -1155,7 +1224,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
false, // skip_running_command_detection
RunningCommandDetection::Detect,
HashMap::new(),
EntrypointType::UserInitiated,
/*is_queued_prompt*/ false,
@@ -1180,7 +1249,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
false, // skip_running_command_detection
RunningCommandDetection::Detect,
HashMap::new(),
EntrypointType::UserInitiated,
/*is_queued_prompt*/ true,
@@ -1202,7 +1271,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
false, // skip_running_command_detection
RunningCommandDetection::Detect,
additional_attachments,
EntrypointType::UserInitiated,
/*is_queued_prompt*/ false,
@@ -1226,7 +1295,7 @@ impl BlocklistAIController {
query,
conversation_id,
participant_id,
true, // skip_running_command_detection
RunningCommandDetection::Skip,
HashMap::new(),
EntrypointType::UserInitiated,
/*is_queued_prompt*/ false,
@@ -1241,7 +1310,7 @@ impl BlocklistAIController {
query: String,
conversation_id: AIConversationId,
participant_id: Option<ParticipantId>,
skip_running_command_detection: bool,
running_command_detection: RunningCommandDetection,
additional_attachments: HashMap<String, AIAgentAttachment>,
entrypoint_type: EntrypointType,
is_queued_prompt: bool,
@@ -1274,6 +1343,14 @@ impl BlocklistAIController {
let (promoted_blocks, task_id, running_command) = {
let mut terminal_model = self.terminal_model.lock();
let running_command_opt = match running_command_detection {
RunningCommandDetection::Detect => {
get_running_command_for_conversation(&terminal_model, conversation_id)
}
RunningCommandDetection::Skip => None,
};
terminal_model
.block_list_mut()
.associate_blocks_with_conversation(context_block_ids.iter(), conversation_id);
@@ -1285,13 +1362,21 @@ impl BlocklistAIController {
.promote_blocks_to_attached_from_conversation(conversation_id);
let active_block = terminal_model.block_list().active_block();
let running_command_opt = if !skip_running_command_detection {
get_running_command(&terminal_model)
} else {
None
};
let existing_cli_task_id = active_block
.is_agent_monitoring()
.then(|| active_block.agent_interaction_metadata())
.flatten()
.filter(|metadata| metadata.conversation_id() == &conversation_id)
.and_then(|metadata| metadata.subagent_task_id().cloned());
let (task_id, running_command) = if let Some(running_command) = running_command_opt {
// Steering for a command that already has a monitor must remain on
// that monitor's task. Creating another optimistic CLI task here
// replaces the active task ID and strands the previous exchange.
// Keep attaching the current running-command snapshot so the
// direct provider continues selecting the CLI-agent model.
let (task_id, running_command) = if let Some(task_id) = existing_cli_task_id {
(task_id, running_command_opt)
} else if let Some(running_command) = running_command_opt {
let history_model = BlocklistAIHistoryModel::handle(ctx);
match history_model.update(ctx, |history_model, ctx| {
history_model.create_cli_subagent_task_for_conversation(
@@ -1307,14 +1392,6 @@ impl BlocklistAIController {
return;
}
}
} else if let Some(task_id) = active_block
.is_agent_monitoring()
.then(|| active_block.agent_interaction_metadata())
.flatten()
.filter(|metadata| metadata.conversation_id() == &conversation_id)
.and_then(|metadata| metadata.subagent_task_id().cloned())
{
(task_id, None)
} else {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history_model.conversation(&conversation_id) else {
@@ -1498,7 +1575,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
None,
vec![],
ctx,
);
@@ -1533,7 +1609,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
conversation_id,
vec![],
ctx,
);
@@ -1599,13 +1674,63 @@ impl BlocklistAIController {
history.mark_active_conversation_id(conversation_id, self.terminal_surface_id, ctx);
});
let finished_results = self.action_model.update(ctx, |action_model, _| {
let mut finished_results = self.action_model.update(ctx, |action_model, _| {
action_model.drain_finished_action_results(conversation_id)
});
if finished_results.is_empty() {
return;
}
// Direct providers do not rely on a hosted orchestrator to create a CLI
// subtask after the initial long-running-command snapshot. Create that
// task locally at the action-result boundary, then route the snapshot
// and every resulting monitor response through it. This is non-preemptive:
// the original model stream has already finished and the action result is
// ready for its normal follow-up.
let initial_cli_block_id =
finished_results
.iter()
.find_map(|result| match &result.result {
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { block_id, .. },
) => Some(block_id.clone()),
_ => None,
});
if let Some(block_id) = initial_cli_block_id {
let cli_task_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
self.terminal_surface_id,
ctx,
)
});
match cli_task_id {
Ok(cli_task_id) => {
for result in &mut finished_results {
if matches!(
&result.result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot {
block_id: result_block_id,
..
}
) if result_block_id == &block_id
) {
result.task_id = cli_task_id.clone();
}
}
}
Err(error) => {
log::error!(
"Could not create direct-provider CLI monitor task for block \
{block_id:?}: {error:?}"
);
}
}
}
// Loop detection: record failures and check for repeated patterns
let loop_warning = self.check_and_record_loop_detection(conversation_id, &finished_results);
@@ -1624,7 +1749,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
vec![],
ctx,
);
@@ -2121,7 +2245,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
additional_context,
ctx,
);
@@ -2523,7 +2646,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
Some(conversation_id),
vec![],
ctx,
),
@@ -2575,7 +2697,6 @@ impl BlocklistAIController {
false,
self.context_model.as_ref(ctx),
self.active_session.as_ref(ctx),
None,
vec![],
ctx,
),
@@ -4128,6 +4249,7 @@ impl BlocklistAIController {
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.clone(),
ContentPart::Image { .. } => "[Image attachment]".to_string(),
ContentPart::ToolUse { name, input, .. } => {
format!("[Tool: {}] {}", name, input)
}
@@ -4236,6 +4358,7 @@ impl BlocklistAIController {
.iter()
.map(|p| match p {
ContentPart::Text(t) => (t.len() / 4) as u32,
ContentPart::Image { .. } => 1_600,
ContentPart::ToolUse { input, .. } => {
(input.to_string().len() / 4) as u32
}
@@ -4363,14 +4486,8 @@ fn input_for_query(
}
}
let context = input_context_for_request(
true,
context_model,
active_session,
Some(conversation_id),
image_context,
app,
);
let context =
input_context_for_request(true, context_model, active_session, image_context, app);
let intended_agent = BlocklistAIHistoryModel::as_ref(app)
.conversation(&conversation_id)
.and_then(|c| c.get_task(task_id))
@@ -4465,8 +4582,34 @@ fn get_running_command(terminal_model: &TerminalModel) -> Option<RunningCommand>
if !active_block.is_active_and_long_running() || active_block.is_agent_monitoring() {
return None;
}
Some(running_command_snapshot(terminal_model))
}
/// Returns the active command when it is unclaimed or already monitored by the
/// requested conversation. This keeps steering on the CLI task and preserves
/// the terminal-specialized model/tool set for every subsequent user turn.
fn get_running_command_for_conversation(
terminal_model: &TerminalModel,
conversation_id: AIConversationId,
) -> Option<RunningCommand> {
let active_block = terminal_model.block_list().active_block();
if !active_block.is_active_and_long_running() {
return None;
}
if active_block.is_agent_monitoring()
&& active_block
.agent_interaction_metadata()
.is_none_or(|metadata| metadata.conversation_id() != &conversation_id)
{
return None;
}
Some(running_command_snapshot(terminal_model))
}
fn running_command_snapshot(terminal_model: &TerminalModel) -> RunningCommand {
let active_block = terminal_model.block_list().active_block();
let is_alt_screen_active = terminal_model.is_alt_screen_active();
Some(RunningCommand {
RunningCommand {
block_id: active_block.id().clone(),
command: active_block.command_to_string(),
grid_contents: if is_alt_screen_active {
@@ -4486,7 +4629,7 @@ fn get_running_command(terminal_model: &TerminalModel) -> Option<RunningCommand>
cursor: CURSOR_MARKER.to_owned(),
requested_command_id: active_block.requested_command_action_id().cloned(),
is_alt_screen_active,
})
}
}
#[cfg(test)]