Yield orchestrators for child groups and correct subscription context catalogs

This commit is contained in:
2026-09-14 22:32:18 -05:00
parent 506dc5b225
commit 72cd38b54e
27 changed files with 741 additions and 115 deletions
+37 -2
View File
@@ -1261,9 +1261,26 @@ impl ProviderRun {
});
};
let next_epoch = self.next_epoch()?;
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)
});
self.transcript.push(tool_result_message(results));
self.epoch = next_epoch;
self.state = ProviderRunState::ReadyToCallModel;
// 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 {
ProviderRunState::Done {
completion: ProviderRunCompletion {
stop_reason: StopReason::Completed,
},
}
} else {
ProviderRunState::ReadyToCallModel
};
Ok(())
}
@@ -1382,12 +1399,25 @@ impl ProviderRun {
fn build_tool_batch(&self, turn: &CompletedModelTurn) -> PendingToolBatch {
let policy = ToolPolicy::from_names(turn.advertised_tools.iter().cloned());
let has_launch = turn.tool_calls.iter().any(|call| {
is_local_agent_launch(call)
&& call.arguments.is_object()
&& turn.advertised_tools.contains(&call.name)
});
let calls = turn
.tool_calls
.iter()
.cloned()
.map(|call| {
let state = if !call.arguments.is_object() {
let state = if has_launch && !is_local_agent_launch(&call) {
PendingToolCallState::Resolved {
result: ToolResult {
call_id: call.id.clone(),
content: "Not executed: launching a child group yields this turn. Wait for the group's completion before scheduling dependent work.".to_string(),
status: ToolResultStatus::Error,
},
}
} else if !call.arguments.is_object() {
PendingToolCallState::Resolved {
result: ToolResult {
call_id: call.id.clone(),
@@ -1626,6 +1656,11 @@ fn usage_contains(total: &Usage, part: &Usage) -> bool {
&& total.cache_creation_input_tokens >= part.cache_creation_input_tokens
}
fn is_local_agent_launch(call: &ToolCall) -> bool {
// Galaxy normalizes direct-provider delegation to local child execution.
matches!(call.name.as_str(), "run_agents" | "start_agent")
}
fn validate_model_turn(turn: &CompletedModelTurn) -> Result<(), ProviderRunProtocolError> {
for part in &turn.assistant_content {
match part {
@@ -1206,20 +1206,20 @@ fn restore_normalization_preserves_selected_executing_tools_for_recovery() {
&mut run,
tool_turn(
vec![
tool_call("run-agents", "run_agents"),
tool_call("command", "run_command"),
tool_call("read", "read_files"),
],
&["run_agents", "read_files"],
&["run_command", "read_files"],
),
);
run.start_tool(&batch.work_id, "run-agents").unwrap();
run.start_tool(&batch.work_id, "command").unwrap();
run.start_tool(&batch.work_id, "read").unwrap();
let normalization = run
.normalize_after_restore_with_recoverable_calls(&HashSet::from(["run-agents".to_string()]))
.normalize_after_restore_with_recoverable_calls(&HashSet::from(["command".to_string()]))
.unwrap();
assert_eq!(normalization.recovery_call_ids, vec!["run-agents"]);
assert_eq!(normalization.recovery_call_ids, vec!["command"]);
assert_eq!(normalization.interrupted_call_ids, vec!["read"]);
let ProviderRunState::AwaitingTools { batch } = run.state() else {
panic!("recovered tool batch should remain pending");
@@ -1252,7 +1252,7 @@ fn recovery_pending_tool_survives_another_restore_and_completes_once() {
restored
.complete_tool(
&batch.work_id,
successful_result("run-agents", "children completed"),
successful_result("run-agents", "children launched"),
)
.unwrap();
assert!(matches!(
@@ -1263,7 +1263,7 @@ fn recovery_pending_tool_survives_another_restore_and_completes_once() {
Err(ProviderRunProtocolError::DuplicateToolUpdate { .. })
));
restored.commit_tool_batch(&batch.work_id).unwrap();
assert_eq!(restored.state().phase(), ProviderRunPhase::ReadyToCallModel);
assert_eq!(restored.state().phase(), ProviderRunPhase::Done);
}
#[test]
@@ -1465,3 +1465,58 @@ fn multipart_archive_is_not_multiplied_and_legacy_duplicates_are_repaired() {
archive_tool_results(&mut archive, transcript);
assert_eq!(archive, expected);
}
#[test]
fn local_child_launch_yields_and_does_not_execute_batched_follow_up_tools() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("children", "run_agents"),
tool_call("edit", "apply_file_diffs"),
],
&["run_agents", "apply_file_diffs"],
),
);
assert!(matches!(
batch.calls[0].state,
PendingToolCallState::Proposed
));
assert!(
matches!(&batch.calls[1].state, PendingToolCallState::Resolved { result }
if result.status == ToolResultStatus::Error)
);
run.complete_tool(
&batch.work_id,
successful_result("children", "Children launched"),
)
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
assert!(matches!(run.state(), ProviderRunState::Done { .. }));
assert_serialization_round_trip(&run);
assert!(matches!(
run.next_step().unwrap(),
Some(ProviderRunStep::Done(_))
));
}
#[test]
fn failed_child_launch_allows_a_corrective_model_turn() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("children", "run_agents")], &["run_agents"]),
);
run.complete_tool(
&batch.work_id,
ToolResult {
call_id: "children".to_string(),
content: "Launch failed".to_string(),
status: ToolResultStatus::Error,
},
)
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
assert!(matches!(run.state(), ProviderRunState::ReadyToCallModel));
}