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 \
|
"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 \
|
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 \
|
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, \
|
result says the command finished, report its outcome and stop polling. If it says the \
|
||||||
poll with `read_shell_command_output` and use short delays. Never choose a poll \
|
command is still running, the next assistant output MUST be a tool call: use \
|
||||||
interval that crosses a user-specified deadline or stop condition. When an explicit \
|
`read_shell_command_output` with a short delay, or call `interrupt_shell_command` \
|
||||||
stop condition is met, call `interrupt_shell_command` immediately, then poll briefly \
|
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 \
|
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 \
|
`\\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 \
|
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!(
|
format!(
|
||||||
"Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\
|
"Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\
|
||||||
Continue monitoring with `read_shell_command_output` using command_id `{}`. \
|
The next assistant output MUST be a tool call: continue monitoring with \
|
||||||
Use `write_to_long_running_shell_command` with the same command_id only if input is \
|
`read_shell_command_output` using command_id `{}` and a short wait. Use \
|
||||||
required. If the user's explicit stop condition is met, use `interrupt_shell_command` \
|
`write_to_long_running_shell_command` with the same command_id only if input is required. \
|
||||||
with the same command_id. Do not report the command as complete while it is still running.",
|
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
|
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("command ID"));
|
||||||
assert!(prompt.contains("read_shell_command_output"));
|
assert!(prompt.contains("read_shell_command_output"));
|
||||||
assert!(prompt.contains("interrupt_shell_command"));
|
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("Never try to encode Ctrl+C"));
|
||||||
assert!(!prompt.contains("- Use `run_shell_command`"));
|
assert!(!prompt.contains("- Use `run_shell_command`"));
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ struct ActiveCLISubagentState {
|
|||||||
initial_requested_command_action_id: Option<AIAgentActionId>,
|
initial_requested_command_action_id: Option<AIAgentActionId>,
|
||||||
task_id: Option<TaskId>,
|
task_id: Option<TaskId>,
|
||||||
last_snapshot_at: Option<Instant>,
|
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>,
|
completion: Option<PendingCommandCompletion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ impl UserTakeOverReason {
|
|||||||
pub fn transfer_reason(&self) -> Option<&str> {
|
pub fn transfer_reason(&self) -> Option<&str> {
|
||||||
match self {
|
match self {
|
||||||
Self::TransferFromAgent { reason } => Some(reason.as_str()),
|
Self::TransferFromAgent { reason } => Some(reason.as_str()),
|
||||||
_ => None,
|
Self::Manual | Self::Stop => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +165,7 @@ impl CLISubagentController {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
me.advance_completed_subagents(*conversation_id, ctx);
|
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 {
|
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.
|
// 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 {
|
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())
|
.entry(snapshot_block_id.clone())
|
||||||
.or_default()
|
.or_default();
|
||||||
.last_snapshot_at = Some(Instant::now());
|
state.last_snapshot_at = Some(Instant::now());
|
||||||
|
state.monitor_nudge_sent = false;
|
||||||
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
||||||
}
|
}
|
||||||
if initial_command_finished_without_snapshot {
|
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(
|
fn finish_subagent(
|
||||||
&mut self,
|
&mut self,
|
||||||
block_id: &BlockId,
|
block_id: &BlockId,
|
||||||
@@ -984,6 +1057,10 @@ fn should_request_completion_assessment(
|
|||||||
.is_some_and(UserTakeOverReason::is_stop)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1013,4 +1090,11 @@ mod tests {
|
|||||||
assert!(should_request_completion_assessment(Some(&agent_state)));
|
assert!(should_request_completion_assessment(Some(&agent_state)));
|
||||||
assert!(should_request_completion_assessment(Some(&transfer_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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn send_user_query_in_conversation_internal(
|
fn send_user_query_in_conversation_internal(
|
||||||
&mut self,
|
&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",
|
"## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n",
|
||||||
),
|
),
|
||||||
RigRequestMode::Cli => prompt.push_str(
|
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");
|
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");
|
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||||
|
|
||||||
assert!(prompt.contains("## Running Command Monitor"));
|
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
|
assert!(prepared
|
||||||
.request
|
.request
|
||||||
.tools
|
.tools
|
||||||
@@ -203,13 +205,14 @@ fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() {
|
|||||||
.any(|message| match &message.content {
|
.any(|message| match &message.content {
|
||||||
MessageContent::ToolResult { content, .. } => {
|
MessageContent::ToolResult { content, .. } => {
|
||||||
content.contains("Command ID: precmd-lrc-test")
|
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| {
|
MessageContent::MultiPart(parts) => parts.iter().any(|part| {
|
||||||
matches!(
|
matches!(
|
||||||
part,
|
part,
|
||||||
ContentPart::ToolResult { content, .. }
|
ContentPart::ToolResult { content, .. }
|
||||||
if content.contains("Command ID: precmd-lrc-test")
|
if content.contains("Command ID: precmd-lrc-test")
|
||||||
|
&& content.contains("The next assistant output MUST be a tool call")
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ fn shell_snapshot_content(
|
|||||||
.map(|is_preempted| format!("\nPreempted: {is_preempted}"))
|
.map(|is_preempted| format!("\nPreempted: {is_preempted}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
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.
|
monitor.
|
||||||
- [x] Monitor teardown now clears orphaned in-memory state when completion metadata is missing.
|
- [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] 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
|
- [ ] 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
|
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
|
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.
|
- Build the initial structured monitoring prompt.
|
||||||
- Reuse `RunningCommand` and shell snapshot conversion where possible.
|
- Reuse `RunningCommand` and shell snapshot conversion where possible.
|
||||||
- Add throttled snapshot updates.
|
- 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.
|
- Add explicit completion/cancellation summaries.
|
||||||
- Redact secrets using the existing input redaction path before sending snapshots to the side agent.
|
- 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.
|
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.
|
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.
|
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.
|
7. Run `count_forever() { i=0; while :; do i=$((i+1)); printf 'tick=%d\\n' "$i"; sleep 1; done; }; count_forever`
|
||||||
8. Trigger limits on both profile models: clear rate-limit message is shown and no crash occurs.
|
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