Run agent shell commands as independent background jobs

This commit is contained in:
Ryan Ward
2026-10-28 17:47:42 +00:00
parent 51782350f1
commit 3cce041a6d
52 changed files with 4536 additions and 681 deletions
+27 -8
View File
@@ -1320,19 +1320,20 @@ impl ProviderRun {
})?);
}
}
let yielded_to_children = batch.calls.iter().any(|pending| {
is_local_agent_launch(&pending.call)
&& pending
.state
.result()
.is_some_and(|result| result.status == ToolResultStatus::Success)
let yielded_to_external_work = batch.calls.iter().any(|pending| {
pending.state.result().is_some_and(|result| {
result.status == ToolResultStatus::Success
&& (is_local_agent_launch(&pending.call)
|| is_background_job_launch(&pending.call, result))
})
});
self.transcript.push(tool_result_message(results));
self.task_list = task_list;
self.epoch = next_epoch;
// Launch acknowledgement is a yield, not an invitation to issue more tools.
// The client persists child membership and supplies a new input once the group finishes.
self.state = if yielded_to_children {
// The client owns the independent child/job lifecycle and supplies a new input once
// that work finishes. Every result in this batch is committed before yielding.
self.state = if yielded_to_external_work {
ProviderRunState::Done {
completion: ProviderRunCompletion {
stop_reason: StopReason::Completed,
@@ -1736,6 +1737,24 @@ fn is_local_agent_launch(call: &ToolCall) -> bool {
matches!(call.name.as_str(), "run_agents" | "start_agent")
}
fn is_background_job_launch(call: &ToolCall, result: &ToolResult) -> bool {
if call.name != "run_shell_command" {
return false;
}
let Ok(content) = serde_json::from_str::<serde_json::Value>(&result.content) else {
return false;
};
content.get("background_job_started").is_some_and(|job| {
job.get("job_id")
.and_then(serde_json::Value::as_str)
.is_some_and(|id| !id.is_empty())
&& job
.get("command")
.and_then(serde_json::Value::as_str)
.is_some()
})
}
fn validate_model_turn(turn: &CompletedModelTurn) -> Result<(), ProviderRunProtocolError> {
for part in &turn.assistant_content {
match part {
@@ -1850,3 +1850,93 @@ fn failed_child_launch_allows_a_corrective_model_turn() {
run.commit_tool_batch(&batch.work_id).unwrap();
assert!(matches!(run.state(), ProviderRunState::ReadyToCallModel));
}
#[test]
fn background_job_launch_yields_after_committing_the_complete_tool_batch() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("build", "run_shell_command"),
tool_call("inspect", "read_files"),
],
&["run_shell_command", "read_files"],
),
);
assert!(
batch
.calls
.iter()
.all(|pending| matches!(pending.state, PendingToolCallState::Proposed))
);
run.complete_tool(
&batch.work_id,
successful_result(
"build",
&json!({
"background_job_started": { "job_id": "job-1", "command": "cargo build" },
})
.to_string(),
),
)
.unwrap();
assert!(run.commit_tool_batch(&batch.work_id).is_err());
run.complete_tool(
&batch.work_id,
successful_result("inspect", "file contents"),
)
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
assert!(matches!(run.state(), ProviderRunState::Done { .. }));
assert_serialization_round_trip(&run);
let MessageContent::MultiPart(parts) = &run.transcript().last().unwrap().content else {
panic!("the batch should remain in provider history");
};
assert_eq!(
parts
.iter()
.filter(|part| matches!(part, ContentPart::ToolResult { .. }))
.count(),
2
);
}
#[test]
fn failed_or_legacy_shell_results_allow_the_normal_corrective_turn() {
for (status, content) in [
(
ToolResultStatus::Error,
json!({"background_job_started": {"job_id": "failed", "command": "build"}}).to_string(),
),
(
ToolResultStatus::Success,
"legacy terminal snapshot".to_owned(),
),
(
ToolResultStatus::Success,
json!({"output": "background_job_started", "exit_code": 0}).to_string(),
),
] {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![tool_call("shell", "run_shell_command")],
&["run_shell_command"],
),
);
run.complete_tool(
&batch.work_id,
ToolResult {
call_id: "shell".to_owned(),
content,
status,
images: Vec::new(),
},
)
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
assert!(matches!(run.state(), ProviderRunState::ReadyToCallModel));
}
}