Keep long-running monitors polling after prose turns
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`"));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user