Keep long-running monitors polling after prose turns

This commit is contained in:
2026-08-06 23:50:51 -05:00
parent 17898e7d9b
commit 603437a24e
9 changed files with 143 additions and 18 deletions
+88 -4
View File
@@ -43,6 +43,10 @@ struct ActiveCLISubagentState {
initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>,
/// Prevents a monitor turn that ended with prose and no tool call from recursively
/// generating nudges. A real snapshot/action result resets this so the next turn can be
/// nudged again if it stalls in the same way.
monitor_nudge_sent: bool,
completion: Option<PendingCommandCompletion>,
}
@@ -67,7 +71,7 @@ impl UserTakeOverReason {
pub fn transfer_reason(&self) -> Option<&str> {
match self {
Self::TransferFromAgent { reason } => Some(reason.as_str()),
_ => None,
Self::Manual | Self::Stop => None,
}
}
}
@@ -161,6 +165,7 @@ impl CLISubagentController {
return;
};
me.advance_completed_subagents(*conversation_id, ctx);
me.ensure_monitor_continues(*conversation_id, ctx);
});
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
@@ -226,10 +231,12 @@ impl CLISubagentController {
// 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
let state = me
.active_subagents_by_block
.entry(snapshot_block_id.clone())
.or_default()
.last_snapshot_at = Some(Instant::now());
.or_default();
state.last_snapshot_at = Some(Instant::now());
state.monitor_nudge_sent = false;
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if initial_command_finished_without_snapshot {
@@ -443,6 +450,72 @@ impl CLISubagentController {
}
}
/// A monitor turn that returns only prose has no action result to trigger the normal
/// action-follow-up path. Nudge that monitor once with the live command context so a model
/// that acknowledged the first snapshot without polling gets another chance to inspect it.
fn ensure_monitor_continues(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(block_id) = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| {
conversation.all_tasks().find_map(|task| {
let block_id = task.cli_subagent_block_id()?;
let state = self.active_subagents_by_block.get(&block_id)?;
if state.task_id.as_ref() != Some(task.id()) || state.completion.is_some() {
return None;
}
let last_exchange_has_action = task.last_exchange().is_some_and(|exchange| {
exchange
.output_status
.output()
.is_some_and(|output| output.get().actions().next().is_some())
});
should_nudge_monitor_turn(last_exchange_has_action, state.monitor_nudge_sent)
.then_some(block_id)
})
})
else {
return;
};
if self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx)
|| self
.action_model
.as_ref(ctx)
.has_unfinished_actions_for_conversation(conversation_id)
{
return;
}
let command_is_still_agent_controlled = {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.block_with_id(&block_id)
.is_some_and(|block| {
block.is_active_and_long_running()
&& block.is_agent_in_control()
&& block.ai_conversation_id() == Some(conversation_id)
})
};
if !command_is_still_agent_controlled {
return;
}
if let Some(state) = self.active_subagents_by_block.get_mut(&block_id) {
state.monitor_nudge_sent = true;
}
self.controller.update(ctx, |controller, ctx| {
controller.send_cli_monitor_nudge(conversation_id, ctx);
});
}
fn finish_subagent(
&mut self,
block_id: &BlockId,
@@ -984,6 +1057,10 @@ fn should_request_completion_assessment(
.is_some_and(UserTakeOverReason::is_stop)
}
fn should_nudge_monitor_turn(last_exchange_has_action: bool, monitor_nudge_sent: bool) -> bool {
!last_exchange_has_action && !monitor_nudge_sent
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1013,4 +1090,11 @@ mod tests {
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
#[test]
fn prose_monitor_turn_is_nudged_once_until_a_tool_action_runs() {
assert!(should_nudge_monitor_turn(false, false));
assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false));
}
}
+24
View File
@@ -1389,6 +1389,30 @@ impl BlocklistAIController {
);
}
/// Nudges a CLI monitor that ended a turn without proposing a polling action. The running
/// command is attached through normal long-running-command detection so Rig and the legacy
/// provider path both receive the monitor-specific prompt and tool set.
pub fn send_cli_monitor_nudge(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
self.send_user_query_in_conversation_internal(
"The command is still running. Continue monitoring now: call `read_shell_command_output` \
with the existing command ID instead of replying with a status message. If the user's \
explicit stop condition is met, call `interrupt_shell_command` immediately."
.to_owned(),
conversation_id,
None,
RunningCommandDetection::Detect,
HashMap::new(),
EntrypointType::AgentInitiated,
/*is_queued_prompt*/ false,
/*queued_query_id*/ None,
ctx,
);
}
#[allow(clippy::too_many_arguments)]
fn send_user_query_in_conversation_internal(
&mut self,