Run agent shell commands as independent background jobs
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user