From 603437a24e81bb6ecf3a729bc8130f34484d9639 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Thu, 6 Aug 2026 23:50:51 -0500 Subject: [PATCH] Keep long-running monitors polling after prose turns --- app/src/ai/bedrock/request_translator.rs | 21 +++-- .../ai/bedrock/request_translator_tests.rs | 2 + app/src/ai/blocklist/block/cli_controller.rs | 92 ++++++++++++++++++- app/src/ai/blocklist/controller.rs | 24 +++++ app/src/ai/runtime/rig_request.rs | 2 +- app/src/ai/runtime/rig_request_tests.rs | 7 +- crates/ai/src/agent/action_result/mod.rs | 2 +- plans/galaxy-local-first-rig.md | 2 + plans/long-running-command-side-agent.md | 9 +- 9 files changed, 143 insertions(+), 18 deletions(-) diff --git a/app/src/ai/bedrock/request_translator.rs b/app/src/ai/bedrock/request_translator.rs index bd5fb26c..99171ec2 100644 --- a/app/src/ai/bedrock/request_translator.rs +++ b/app/src/ai/bedrock/request_translator.rs @@ -1496,10 +1496,13 @@ pub fn extract_system_prompt( "This turn concerns a running or just-finished shell command. Act as its dedicated \ monitor while still following the user's steering messages. Use the command ID from \ the running-command context or tool result for every read/write operation. If the \ - result says the command finished, report its outcome and stop polling. Otherwise, \ - poll with `read_shell_command_output` and use short delays. Never choose a poll \ - interval that crosses a user-specified deadline or stop condition. When an explicit \ - stop condition is met, call `interrupt_shell_command` immediately, then poll briefly \ + result says the command finished, report its outcome and stop polling. If it says the \ + command is still running, the next assistant output MUST be a tool call: use \ + `read_shell_command_output` with a short delay, or call `interrupt_shell_command` \ + immediately when the user's explicit stop condition is met. Do not end a still-running \ + monitor turn with prose, a status message, or a request for the user to say continue. \ + Never choose a poll interval that crosses a user-specified deadline or stop condition. \ + After an interrupt, poll briefly \ to verify the outcome. Never try to encode Ctrl+C as `C-c`, `^C`, `\\x03`, or \ `\\u0003` through `write_to_long_running_shell_command`; that tool is only for actual \ process input. Never start a duplicate command merely to check its state, and never \ @@ -2393,10 +2396,12 @@ fn long_running_command_content(snapshot: &api::LongRunningShellCommandSnapshot) }; format!( "Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\ - Continue monitoring with `read_shell_command_output` using command_id `{}`. \ - Use `write_to_long_running_shell_command` with the same command_id only if input is \ - required. If the user's explicit stop condition is met, use `interrupt_shell_command` \ - with the same command_id. Do not report the command as complete while it is still running.", + The next assistant output MUST be a tool call: continue monitoring with \ + `read_shell_command_output` using command_id `{}` and a short wait. Use \ + `write_to_long_running_shell_command` with the same command_id only if input is required. \ + If the user's explicit stop condition is met, use `interrupt_shell_command` immediately \ + with the same command_id. Do not end this turn with prose or report the command as complete \ + while it is still running.", snapshot.command_id, output, snapshot.command_id ) } diff --git a/app/src/ai/bedrock/request_translator_tests.rs b/app/src/ai/bedrock/request_translator_tests.rs index 1456430f..57485fcc 100644 --- a/app/src/ai/bedrock/request_translator_tests.rs +++ b/app/src/ai/bedrock/request_translator_tests.rs @@ -299,6 +299,8 @@ fn running_command_turn_gets_monitor_prompt_and_cli_tools() { assert!(prompt.contains("command ID")); assert!(prompt.contains("read_shell_command_output")); assert!(prompt.contains("interrupt_shell_command")); + assert!(prompt.contains("next assistant output MUST be a tool call")); + assert!(prompt.contains("Do not end a still-running monitor turn with prose")); assert!(prompt.contains("Never try to encode Ctrl+C")); assert!(!prompt.contains("- Use `run_shell_command`")); diff --git a/app/src/ai/blocklist/block/cli_controller.rs b/app/src/ai/blocklist/block/cli_controller.rs index 7a7e8354..d0113d19 100644 --- a/app/src/ai/blocklist/block/cli_controller.rs +++ b/app/src/ai/blocklist/block/cli_controller.rs @@ -43,6 +43,10 @@ struct ActiveCLISubagentState { initial_requested_command_action_id: Option, task_id: Option, last_snapshot_at: Option, + /// 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, } @@ -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, + ) { + 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)); + } } diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index f8c5f9dc..7fcfbfeb 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -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.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, diff --git a/app/src/ai/runtime/rig_request.rs b/app/src/ai/runtime/rig_request.rs index ab63a8c6..29fa646e 100644 --- a/app/src/ai/runtime/rig_request.rs +++ b/app/src/ai/runtime/rig_request.rs @@ -737,7 +737,7 @@ fn build_system_prompt( "## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n", ), RigRequestMode::Cli => prompt.push_str( - "## Running Command Monitor\nThis turn concerns a running or just-finished shell command. Act as its dedicated monitor while still following the user's steering messages. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. Otherwise, poll with `read_shell_command_output` and use short delays. Never choose a poll interval that crosses a user-specified deadline or stop condition. When an explicit stop condition is met, call `interrupt_shell_command` immediately, then poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n", + "## Running Command Monitor\nThis turn concerns a running or just-finished shell command. Act as its dedicated monitor while still following the user's steering messages. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. If it says the command is still running, the next assistant output MUST be a tool call: use `read_shell_command_output` with a short delay, or use `interrupt_shell_command` immediately when the user's explicit stop condition is met. Do not end a still-running monitor turn with prose, a status message, or a request for the user to say continue. Never choose a poll interval that crosses a user-specified deadline or stop condition. After an interrupt, poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n", ), } prompt.push_str("## Available Tools\n"); diff --git a/app/src/ai/runtime/rig_request_tests.rs b/app/src/ai/runtime/rig_request_tests.rs index 5f0af4a2..a3077bb0 100644 --- a/app/src/ai/runtime/rig_request_tests.rs +++ b/app/src/ai/runtime/rig_request_tests.rs @@ -185,7 +185,9 @@ fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() { let prompt = prepared.request.system_prompt.expect("system prompt"); assert!(prompt.contains("## Running Command Monitor")); - assert!(prompt.contains("poll with `read_shell_command_output`")); + assert!(prompt.contains("`read_shell_command_output` with a short delay")); + assert!(prompt.contains("next assistant output MUST be a tool call")); + assert!(prompt.contains("Do not end a still-running monitor turn with prose")); assert!(prepared .request .tools @@ -203,13 +205,14 @@ fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() { .any(|message| match &message.content { MessageContent::ToolResult { content, .. } => { content.contains("Command ID: precmd-lrc-test") - && content.contains("Continue monitoring with `read_shell_command_output`") + && content.contains("The next assistant output MUST be a tool call") } MessageContent::MultiPart(parts) => parts.iter().any(|part| { matches!( part, ContentPart::ToolResult { content, .. } if content.contains("Command ID: precmd-lrc-test") + && content.contains("The next assistant output MUST be a tool call") ) }), _ => false, diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index 735cd526..6e493028 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -327,7 +327,7 @@ fn shell_snapshot_content( .map(|is_preempted| format!("\nPreempted: {is_preempted}")) .unwrap_or_default(); format!( - "{command}Command is still running.\nCommand ID: {block_id}\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}\nContinue monitoring with `read_shell_command_output` using command_id `{block_id}` and a short wait. Do not report completion while the command is still running." + "{command}Command is still running.\nCommand ID: {block_id}\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}\nThe next assistant output MUST be a tool call: continue monitoring with `read_shell_command_output` using command_id `{block_id}` and a short wait. If the user's explicit stop condition is met, call `interrupt_shell_command` immediately. Do not end this turn with prose or report completion while the command is still running." ) } diff --git a/plans/galaxy-local-first-rig.md b/plans/galaxy-local-first-rig.md index 8193fa11..7f25a650 100644 --- a/plans/galaxy-local-first-rig.md +++ b/plans/galaxy-local-first-rig.md @@ -432,6 +432,8 @@ assigned to the phase that owns the affected flow before the related work is con monitor. - [x] Monitor teardown now clears orphaned in-memory state when completion metadata is missing. - [x] Refresh requests ignore completed or no-longer-long-running blocks. + - [x] A monitor turn that ends after a snapshot without a polling action now receives one bounded + continuation nudge, with shared prompts requiring a tool call while the command is running. - [ ] ChatGPT subscription follow-up: a reported OAuth-backed tool turn failed because the Responses request lacked `call_id`. Rig's stream fallback and the serialized assistant/tool follow-up are now covered by hermetic tests; complete a fresh authenticated end-to-end check and diff --git a/plans/long-running-command-side-agent.md b/plans/long-running-command-side-agent.md index 2a1ae479..345176f2 100644 --- a/plans/long-running-command-side-agent.md +++ b/plans/long-running-command-side-agent.md @@ -207,6 +207,8 @@ Tasks: - Build the initial structured monitoring prompt. - Reuse `RunningCommand` and shell snapshot conversion where possible. - Add throttled snapshot updates. +- [x] If a monitor turn finishes with a still-running command but no polling tool call, issue one + bounded continuation nudge; reset that guard after a real snapshot/action result. - Add explicit completion/cancellation summaries. - Redact secrets using the existing input redaction path before sending snapshots to the side agent. @@ -264,5 +266,8 @@ Manual verification: 4. Run a command with no output but successful completion: side agent does not claim failure prematurely and receives the final exit code. 5. Run a command that exits non-zero: side agent identifies failure and main agent receives the summary. 6. Run a simulated database lock/wait: side agent flags suspicious lack of progress with uncertainty. -7. Trigger a provider budget limit on the thinking model: coding model is tried once. -8. Trigger limits on both profile models: clear rate-limit message is shown and no crash occurs. +7. Run `count_forever() { i=0; while :; do i=$((i+1)); printf 'tick=%d\\n' "$i"; sleep 1; done; }; count_forever` + and ask the monitor to stop it at tick 50; verify it continues polling after each snapshot and + interrupts the original process at the requested point. +8. Trigger a provider budget limit on the thinking model: coding model is tried once. +9. Trigger limits on both profile models: clear rate-limit message is shown and no crash occurs.