Run agent shell commands as independent background jobs
This commit is contained in:
@@ -319,7 +319,7 @@ fn prepare_rig_turn_for_provider(
|
||||
if !command_allowlist.is_empty() {
|
||||
system_prompt.push_str(&format!("\nExplicit command allowlist (regular expressions): {}. Matching shell commands are permitted even in Read Only; denylist and workspace restrictions still win. Do not broaden a command to exploit a pattern.\n", serde_json::to_string(&command_allowlist).unwrap_or_default()));
|
||||
}
|
||||
system_prompt.push_str("Approval of a long-running command covers monitoring and interaction with that same conversation-owned command while you retain control. Use the existing command block; do not request permission again or restart the command just to interact with it. A new command requires its own permission decision.\n");
|
||||
system_prompt.push_str("Approval covers starting that exact background command and terminating its owned process tree. It does not authorize another command or interactive process input. Every new command follows the current permission mode.\n");
|
||||
|
||||
let mut new_messages = input_messages(input, tool_results, max_context_tokens);
|
||||
let mut persistent_messages = message_history;
|
||||
@@ -461,6 +461,9 @@ fn input_message(
|
||||
context,
|
||||
completed_command,
|
||||
} => {
|
||||
if completed_command.block_id.as_str().starts_with("background-job:") {
|
||||
(prompt, image_parts(&context))
|
||||
} else {
|
||||
let output = bounded_terminal_output_for_provider_request(
|
||||
&completed_command.grid_contents,
|
||||
max_context_tokens,
|
||||
@@ -472,6 +475,7 @@ fn input_message(
|
||||
),
|
||||
image_parts(&context),
|
||||
)
|
||||
}
|
||||
}
|
||||
AIAgentInput::ActionResult { .. } => return None,
|
||||
AIAgentInput::AutoCodeDiffQuery { query, .. } => (query, Vec::new()),
|
||||
@@ -649,10 +653,10 @@ pub(crate) enum RigRequestMode {
|
||||
}
|
||||
|
||||
fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode {
|
||||
if inputs
|
||||
.iter()
|
||||
.any(|input| matches!(input, AIAgentInput::CommandCompletionAssessment { .. }))
|
||||
{
|
||||
if inputs.iter().any(|input| {
|
||||
matches!(input, AIAgentInput::CommandCompletionAssessment { .. })
|
||||
&& !input.is_background_job_completion()
|
||||
}) {
|
||||
return RigRequestMode::CompletedCommandAssessment;
|
||||
}
|
||||
|
||||
@@ -859,7 +863,7 @@ fn build_system_prompt(
|
||||
"## Task Tracking\nUse task tracking only for substantial work with multiple concrete steps, after planning has established those steps or when the user provides an actionable plan. Skip it for simple requests, questions, and exploratory planning. Each task must name a specific change or deliverable with a clear completion condition. Do not create generic workflow items such as 'research the request', 'critique the plan', 'execute the plan', or 'verify the result'. Update tasks only when their actual work is complete; a tool call or model turn alone is not evidence of completion.\n\n",
|
||||
);
|
||||
prompt.push_str(
|
||||
"## Shell Working Directory\nShell commands run in this conversation's persistent terminal session. The supplied working directory is the shell's location when the request was prepared. Start there without a redundant `cd` or `pwd`. Track successful directory changes across tool calls; do not assume later commands reset to the project root. Use `cd` only when the next command needs a different directory, and use `pwd` only when the location is missing or uncertain. A `cd` inside a subshell or separate script does not change the parent shell's directory. Each sub-agent has its own terminal; never assume it shares another agent's directory changes.\n\n",
|
||||
"## Shell Working Directory\nAgent commands run as independent background jobs in the session directory supplied with this request. Directory and environment changes inside one job do not change later jobs or the user's terminal. Use explicit paths or cd within the same job when a command needs another directory.\n\n",
|
||||
);
|
||||
prompt.push_str(SHELL_EXECUTION_GUIDANCE);
|
||||
prompt.push_str(PROGRESS_COMMUNICATION_GUIDANCE);
|
||||
@@ -1046,7 +1050,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\nKeep an eye on the existing command while continuing the user's request. 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 the result says the command is still running, make the polling tool call. Only when the latest output adds material information, precede it with one concise, user-facing sentence describing what changed; otherwise poll without repeating the previous status. Do not send a text-only progress response, repeat a generic 'still running' message, or mention internal polling mechanics. If the snapshot clearly shows an interactive pager or editor, do not keep polling: an alternate screen containing `(END)` is `less`, so call `write_to_long_running_shell_command` with input `q` and mode `raw`; for a clearly identified Vim screen, send input `:q` with mode `line`. Poll briefly after sending quit input to verify the outcome. Use `interrupt_shell_command` immediately when the user's explicit stop condition is met. 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",
|
||||
"## Legacy Command Context\nAgent command monitoring and interactive terminal access are retired. Do not poll, send process input, or transfer terminal control. New commands run as independent noninteractive background jobs.\n\n",
|
||||
),
|
||||
RigRequestMode::CompletedCommandAssessment => prompt.push_str(
|
||||
"## Completed Command Assessment\nThe monitored command has finished. Use its command, command ID, final terminal output, and the assessment instruction in the latest hidden input to provide the final user-facing outcome. Do not continue polling, request more terminal output, or call tools.\n\n",
|
||||
|
||||
@@ -531,7 +531,7 @@ fn completed_command_assessment_bounds_only_the_provider_payload() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_cli_profile_uses_monitor_prompt_and_tools_for_an_initial_query() {
|
||||
fn forced_legacy_cli_profile_retires_monitoring_and_interactive_tools() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![user_query("Run the checks")];
|
||||
|
||||
@@ -544,27 +544,64 @@ fn forced_cli_profile_uses_monitor_prompt_and_tools_for_an_initial_query() {
|
||||
);
|
||||
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||
|
||||
assert!(prompt.contains("## Running Command Monitor"));
|
||||
assert!(prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "read_shell_command_output"));
|
||||
assert!(!prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "run_shell_command"));
|
||||
assert!(!prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "recall_tool_history"));
|
||||
assert!(!prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "update_task_list"));
|
||||
assert!(prompt.contains("## Legacy Command Context"));
|
||||
assert!(prompt.contains("Do not poll, send process input, or transfer terminal control"));
|
||||
assert!(prompt.contains("independent noninteractive background jobs"));
|
||||
assert!(!prompt.contains("## Running Command Monitor"));
|
||||
assert!(prepared.request.tools.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_job_completion_is_a_hidden_normal_turn_with_base_tools() {
|
||||
let prompt = "[BACKGROUND JOB COMPLETION]\nCommand: cargo build\nExit code: 0\nCommand logs:\nFinished build";
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.root_task_id = Some("root-task".to_string());
|
||||
params.message_history = vec![galaxy_agent_core::ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Build and review the requested change".to_string()),
|
||||
}];
|
||||
params.input = vec![AIAgentInput::CommandCompletionAssessment {
|
||||
prompt: prompt.to_string(),
|
||||
context: Arc::from([]),
|
||||
completed_command: crate::ai::agent::RunningCommand {
|
||||
command: "cargo build".to_string(),
|
||||
block_id: "background-job:11111111-1111-4111-8111-111111111111"
|
||||
.to_string()
|
||||
.into(),
|
||||
grid_contents: String::new(),
|
||||
cursor: String::new(),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
},
|
||||
}];
|
||||
assert_eq!(super::request_mode(¶ms.input), RigRequestMode::Normal);
|
||||
|
||||
let prepared = prepare_rig_turn(
|
||||
&config(),
|
||||
params,
|
||||
vec![ToolType::RunShellCommand, ToolType::ReadFiles],
|
||||
vec![ToolType::ReadShellCommandOutput],
|
||||
);
|
||||
assert_eq!(prepared.task_id, "root-task");
|
||||
assert_eq!(prepared.user_query, None);
|
||||
for name in ["run_shell_command", "read_files", "update_task_list"] {
|
||||
assert!(prepared.request.tools.iter().any(|tool| tool.name == name));
|
||||
}
|
||||
assert!(!prepared.request.tools.iter().any(|tool| matches!(
|
||||
tool.name.as_str(),
|
||||
"read_shell_command_output" | "write_to_long_running_shell_command"
|
||||
)));
|
||||
let system_prompt = prepared.request.system_prompt.expect("system prompt");
|
||||
assert!(!system_prompt.contains("## Completed Command Assessment"));
|
||||
assert!(!system_prompt.contains("## Legacy Command Context"));
|
||||
assert!(prepared.persistent_messages.iter().any(|message| matches!(
|
||||
&message.content,
|
||||
MessageContent::Text(text) if text == "Build and review the requested change"
|
||||
)));
|
||||
assert!(prepared.persistent_messages.iter().any(|message| matches!(
|
||||
&message.content,
|
||||
MessageContent::Text(text) if text == prompt
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -590,7 +627,7 @@ fn task_progress_is_available_with_explicit_status_guidance_and_ui_title() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() {
|
||||
fn legacy_lrc_snapshot_retains_recorded_output_without_monitoring_tools() {
|
||||
let block_id: galaxy_terminal::model::BlockId = "precmd-lrc-test".to_string().into();
|
||||
let result = AIAgentActionResult {
|
||||
id: AIAgentActionId::from("run-call".to_owned()),
|
||||
@@ -637,25 +674,10 @@ 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("Use the command ID from the tool result for every read/write operation")
|
||||
);
|
||||
assert!(prompt.contains("one concise, user-facing sentence"));
|
||||
assert!(prompt.contains("Do not send a text-only progress response"));
|
||||
assert!(prompt.contains("alternate screen containing `(END)` is `less`"));
|
||||
assert!(prompt.contains("`write_to_long_running_shell_command` with input `q` and mode `raw`"));
|
||||
assert!(prompt.contains("make the polling tool call in the same response"));
|
||||
assert!(prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "read_shell_command_output"));
|
||||
assert!(!prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "recall_tool_history"));
|
||||
assert!(prompt.contains("## Legacy Command Context"));
|
||||
assert!(prompt.contains("Do not poll, send process input, or transfer terminal control"));
|
||||
assert!(!prompt.contains("## Running Command Monitor"));
|
||||
assert!(prepared.request.tools.is_empty());
|
||||
assert!(prepared
|
||||
.request
|
||||
.messages
|
||||
@@ -663,14 +685,18 @@ 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("The next assistant output MUST be a tool call")
|
||||
&& content.contains("Running for 2 seconds...")
|
||||
&& content.contains("command monitoring are retired")
|
||||
&& !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")
|
||||
&& content.contains("Running for 2 seconds...")
|
||||
&& content.contains("command monitoring are retired")
|
||||
&& !content.contains("The next assistant output MUST be a tool call")
|
||||
)
|
||||
}),
|
||||
_ => false,
|
||||
|
||||
@@ -49,9 +49,8 @@ pub(crate) fn action_from_tool_call(
|
||||
command: required_nonempty_string(input, "command")?,
|
||||
is_read_only: Some(optional_boolean(input, "is_read_only")?.unwrap_or(false)),
|
||||
is_risky: Some(optional_boolean(input, "is_risky")?.unwrap_or(false)),
|
||||
wait_until_completion: optional_boolean(input, "wait_until_complete")?
|
||||
.unwrap_or(false),
|
||||
uses_pager: Some(optional_boolean(input, "uses_pager")?.unwrap_or(false)),
|
||||
wait_until_completion: false,
|
||||
uses_pager: Some(false),
|
||||
rationale: optional_string(input, "rationale")?,
|
||||
citations: Vec::new(),
|
||||
},
|
||||
@@ -114,7 +113,10 @@ pub(crate) fn action_from_tool_call(
|
||||
}
|
||||
}
|
||||
"interrupt_shell_command" => AIAgentActionType::WriteToLongRunningShellCommand {
|
||||
block_id: required_nonempty_string(input, "command_id")?.into(),
|
||||
block_id: optional_string(input, "job_id")?
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or(optional_string(input, "command_id")?)
|
||||
.ok_or("job_id is required to cancel a background job")?.into(),
|
||||
input: vec![galaxy_terminal::model::escape_sequences::C0::ETX].into(),
|
||||
mode: AIAgentPtyWriteMode::Raw,
|
||||
},
|
||||
|
||||
@@ -262,7 +262,7 @@ fn known_tools_reject_malformed_required_inputs() {
|
||||
(
|
||||
"interrupt_shell_command",
|
||||
serde_json::json!({}),
|
||||
"command_id",
|
||||
"job_id",
|
||||
),
|
||||
(
|
||||
"read_shell_command_output",
|
||||
|
||||
Reference in New Issue
Block a user