Yield orchestrators for child groups and correct subscription context catalogs
This commit is contained in:
@@ -1766,7 +1766,7 @@ impl RunAgentsResult {
|
||||
"instruction": if children_completed {
|
||||
"Child agents reached terminal states. Use their structured outputs and errors to complete the task."
|
||||
} else {
|
||||
"Child agents have only been launched, not completed. Wait for child-agent updates before repeating this work or reporting final results."
|
||||
"Child agents are running independently. This turn yields automatically; the user may continue talking to the orchestrator. One group-completion message will arrive after every child finishes. Do not repeat delegated work or report final results before that message."
|
||||
},
|
||||
"model_id": model_id,
|
||||
"harness_type": harness_type,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1075,6 +1075,17 @@ pub struct AcpConversationData {
|
||||
}
|
||||
|
||||
// Serializes to `conversation_data` column in `agent_conversations`.
|
||||
/// Durable membership and delivery revision for one local delegation group.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LocalAgentGroup {
|
||||
pub id: String,
|
||||
pub children: Vec<String>,
|
||||
pub launch_errors: Vec<String>,
|
||||
pub sealed: bool,
|
||||
pub revision: u64,
|
||||
pub notified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct AgentConversationData {
|
||||
#[serde(default, skip_serializing_if = "AgentBackend::is_provider")]
|
||||
@@ -1082,6 +1093,9 @@ pub struct AgentConversationData {
|
||||
/// Versioned application-owned snapshot for an active direct-provider run.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_provider_run_json: Option<String>,
|
||||
/// Local child groups outlive the parent generation that launched them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub local_agent_groups: Vec<LocalAgentGroup>,
|
||||
pub server_conversation_token: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_usage_metadata: Option<ConversationUsageMetadata>,
|
||||
|
||||
@@ -138,6 +138,7 @@ fn agent_conversation_data_roundtrips_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -241,6 +242,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -269,6 +271,7 @@ fn agent_conversation_data_roundtrips_optimistic_root_marker() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -309,6 +312,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -339,6 +343,7 @@ fn agent_conversation_data_roundtrips_pinned() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
@@ -367,6 +372,7 @@ fn agent_conversation_data_skips_serializing_unpinned() {
|
||||
let data = AgentConversationData {
|
||||
agent_backend: Default::default(),
|
||||
active_provider_run_json: None,
|
||||
local_agent_groups: Vec::new(),
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
|
||||
Reference in New Issue
Block a user