Yield orchestrators for child groups and correct subscription context catalogs
This commit is contained in:
@@ -166,8 +166,8 @@ Key invariants:
|
|||||||
- Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration
|
- Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration
|
||||||
- ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun`
|
- ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun`
|
||||||
- Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools
|
- Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools
|
||||||
- Direct-provider `RunAgents` remains pending until every local child reaches `Success`, `Error`, or `Cancelled`, or is removed/deleted; recoverable `Blocked`, `TransientError`, and `WaitingForEvents` states remain pending, and the hosted 30-second startup timeout must not apply to these completion waits
|
- Direct-provider `RunAgents` acknowledges linked local children and yields the parent generation after committing its launch results. Other tools in that launch batch are explicitly not executed. Persistent `LocalAgentGroup` membership survives parent follow-ups; only when every child reaches `Success`, `Error`, or `Cancelled` (with provider cleanup complete), or is removed/deleted, does one combined completion message wake the parent. `Blocked`, `TransientError`, and `WaitingForEvents` children remain pending. Restarts invalidate undelivered group results, and group delivery is confirmed only after the parent input is accepted.
|
||||||
- `StartAgentWaitPolicy` is selected from child execution mode, not parent `run_id`: local children wait for completion and only remote/hosted children use startup acknowledgement
|
- `StartAgentWaitPolicy` follows child execution ownership, not parent `run_id`: grouped local children use `LocalStartup`, legacy local completion waits retain `Completion`, and hosted children use `Startup` with the hosted timeout. Direct-provider `start_agent` is normalized to a one-child `RunAgents` group. Cancelling or replacing a parent turn must not cancel independently running children or erase their group membership.
|
||||||
- Hosted `RunAgents` startup timeouts detach the exact `StartAgentRequestId`; late launch callbacks must not register the child after the timeout result, while children linked before cancellation remain independently running
|
- Hosted `RunAgents` startup timeouts detach the exact `StartAgentRequestId`; late launch callbacks must not register the child after the timeout result, while children linked before cancellation remain independently running
|
||||||
|
|
||||||
### Platform Setup
|
### Platform Setup
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
|||||||
RestorationMode::Fork => AgentConversationData {
|
RestorationMode::Fork => AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: usage_metadata,
|
conversation_usage_metadata: usage_metadata,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
@@ -98,6 +99,7 @@ pub fn convert_conversation_data_to_ai_conversation(
|
|||||||
RestorationMode::Continue => AgentConversationData {
|
RestorationMode::Continue => AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: Some(
|
server_conversation_token: Some(
|
||||||
metadata.server_conversation_token.as_str().to_string(),
|
metadata.server_conversation_token.as_str().to_string(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use galaxy_core::ui::theme::GalaxyTheme;
|
|||||||
use galaxyui::color::ColorU;
|
use galaxyui::color::ColorU;
|
||||||
use galaxyui::{AppContext, EntityId, ModelContext, SingletonEntity};
|
use galaxyui::{AppContext, EntityId, ModelContext, SingletonEntity};
|
||||||
use itertools::Itertools as _;
|
use itertools::Itertools as _;
|
||||||
|
use persistence::model::LocalAgentGroup;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vec1::{Size0Error, Vec1};
|
use vec1::{Size0Error, Vec1};
|
||||||
@@ -232,6 +233,7 @@ pub struct AIConversation {
|
|||||||
|
|
||||||
/// Opaque, versioned snapshot of the active direct-provider run.
|
/// Opaque, versioned snapshot of the active direct-provider run.
|
||||||
active_provider_run_json: Option<String>,
|
active_provider_run_json: Option<String>,
|
||||||
|
local_agent_groups: Vec<LocalAgentGroup>,
|
||||||
|
|
||||||
/// The server-generated unique "token" for this conversation.
|
/// The server-generated unique "token" for this conversation.
|
||||||
///
|
///
|
||||||
@@ -386,6 +388,7 @@ impl AIConversation {
|
|||||||
conversation_usage_metadata: ConversationUsageMetadata::default(),
|
conversation_usage_metadata: ConversationUsageMetadata::default(),
|
||||||
agent_backend,
|
agent_backend,
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
task_id: None,
|
task_id: None,
|
||||||
forked_from_server_conversation_token: None,
|
forked_from_server_conversation_token: None,
|
||||||
@@ -544,6 +547,10 @@ impl AIConversation {
|
|||||||
(task_store, todo_lists, status)
|
(task_store, todo_lists, status)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let local_agent_groups = conversation_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|data| data.local_agent_groups.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
let (
|
let (
|
||||||
agent_backend,
|
agent_backend,
|
||||||
active_provider_run_json,
|
active_provider_run_json,
|
||||||
@@ -674,6 +681,7 @@ impl AIConversation {
|
|||||||
conversation_usage_metadata,
|
conversation_usage_metadata,
|
||||||
agent_backend,
|
agent_backend,
|
||||||
active_provider_run_json,
|
active_provider_run_json,
|
||||||
|
local_agent_groups,
|
||||||
server_conversation_token,
|
server_conversation_token,
|
||||||
task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
|
task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
|
||||||
forked_from_server_conversation_token,
|
forked_from_server_conversation_token,
|
||||||
@@ -717,6 +725,14 @@ impl AIConversation {
|
|||||||
&self.agent_backend
|
&self.agent_backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn local_agent_groups(&self) -> &[LocalAgentGroup] {
|
||||||
|
&self.local_agent_groups
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_local_agent_groups(&mut self, groups: Vec<LocalAgentGroup>) {
|
||||||
|
self.local_agent_groups = groups;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn active_provider_run_json(&self) -> Option<&str> {
|
pub(crate) fn active_provider_run_json(&self) -> Option<&str> {
|
||||||
self.active_provider_run_json.as_deref()
|
self.active_provider_run_json.as_deref()
|
||||||
}
|
}
|
||||||
@@ -4033,6 +4049,7 @@ impl AIConversation {
|
|||||||
conversation_data: AgentConversationData {
|
conversation_data: AgentConversationData {
|
||||||
agent_backend: self.agent_backend.clone(),
|
agent_backend: self.agent_backend.clone(),
|
||||||
active_provider_run_json: self.active_provider_run_json.clone(),
|
active_provider_run_json: self.active_provider_run_json.clone(),
|
||||||
|
local_agent_groups: self.local_agent_groups.clone(),
|
||||||
server_conversation_token: self
|
server_conversation_token: self
|
||||||
.server_conversation_token
|
.server_conversation_token
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use crate::ai::agent::{
|
|||||||
};
|
};
|
||||||
use crate::ai::auth_secret_types::auth_secret_types_for_harness;
|
use crate::ai::auth_secret_types::auth_secret_types_for_harness;
|
||||||
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
|
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
|
||||||
|
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
||||||
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
||||||
use crate::ai::document::plan_publication::{
|
use crate::ai::document::plan_publication::{
|
||||||
@@ -180,6 +181,9 @@ impl RunAgentsExecutor {
|
|||||||
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
|
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
|
||||||
);
|
);
|
||||||
if self.pending.remove(&action_key).is_some() {
|
if self.pending.remove(&action_key).is_some() {
|
||||||
|
OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||||
|
service.seal_local_group(conversation_id, &action_id.to_string(), Vec::new(), ctx);
|
||||||
|
});
|
||||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
action_id: action_id.clone(),
|
action_id: action_id.clone(),
|
||||||
@@ -423,15 +427,30 @@ impl RunAgentsExecutor {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if matches!(run_execution_mode, RunAgentsExecutionMode::Local) {
|
||||||
|
OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||||
|
service.begin_local_group(parent_conversation_id, &action_id.to_string(), ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
|
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
|
||||||
let wait_policy = match &run_execution_mode {
|
let wait_policy = match &run_execution_mode {
|
||||||
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::Completion,
|
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::LocalStartup,
|
||||||
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
|
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
|
||||||
};
|
};
|
||||||
for cfg in &agent_run_configs {
|
for cfg in &agent_run_configs {
|
||||||
let normalized_name = normalize_agent_name(&cfg.name)
|
let normalized_name = normalize_agent_name(&cfg.name)
|
||||||
.expect("validated RunAgents requests have non-empty agent names");
|
.expect("validated RunAgents requests have non-empty agent names");
|
||||||
if let Some(child_conversation_id) = recovery_children.remove(&normalized_name) {
|
if let Some(child_conversation_id) = recovery_children.remove(&normalized_name) {
|
||||||
|
if matches!(run_execution_mode, RunAgentsExecutionMode::Local) {
|
||||||
|
OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||||
|
service.register_local_group_child(
|
||||||
|
parent_conversation_id,
|
||||||
|
&action_id.to_string(),
|
||||||
|
child_conversation_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
||||||
executor.reattach(
|
executor.reattach(
|
||||||
action_id.clone(),
|
action_id.clone(),
|
||||||
@@ -509,7 +528,7 @@ impl RunAgentsExecutor {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
||||||
executor.dispatch(
|
let mut dispatch = executor.dispatch(
|
||||||
action_id.clone(),
|
action_id.clone(),
|
||||||
cfg.name.clone(),
|
cfg.name.clone(),
|
||||||
prompt,
|
prompt,
|
||||||
@@ -518,7 +537,11 @@ impl RunAgentsExecutor {
|
|||||||
parent_conversation_id,
|
parent_conversation_id,
|
||||||
parent_run_id.clone(),
|
parent_run_id.clone(),
|
||||||
exec_ctx,
|
exec_ctx,
|
||||||
)
|
);
|
||||||
|
if matches!(run_execution_mode, RunAgentsExecutionMode::Local) {
|
||||||
|
executor.yield_after_local_start(&mut dispatch);
|
||||||
|
}
|
||||||
|
dispatch
|
||||||
});
|
});
|
||||||
slots.push(ChildSlot::Pending(dispatch));
|
slots.push(ChildSlot::Pending(dispatch));
|
||||||
}
|
}
|
||||||
@@ -583,6 +606,16 @@ impl RunAgentsExecutor {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
me.record_launched_agents(parent_conversation_id_for_result, &agents);
|
me.record_launched_agents(parent_conversation_id_for_result, &agents);
|
||||||
|
if matches!(run_execution_mode_for_aggr, RunAgentsExecutionMode::Local) {
|
||||||
|
let errors = agents.iter().filter_map(|agent| match &agent.kind {
|
||||||
|
RunAgentsAgentOutcomeKind::Failed { error } => Some(format!("{}: {error}", agent.name)),
|
||||||
|
RunAgentsAgentOutcomeKind::Launched { .. } | RunAgentsAgentOutcomeKind::Completed { .. } => None,
|
||||||
|
}).collect();
|
||||||
|
OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||||
|
service.seal_local_group(parent_conversation_id_for_result, &action_id_for_aggr.to_string(), errors, ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
log_run_agents_event(
|
log_run_agents_event(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -838,7 +871,9 @@ async fn resolve_child_slot_with_timeout(
|
|||||||
let request_id = dispatch.request_id;
|
let request_id = dispatch.request_id;
|
||||||
|
|
||||||
let outcome = match dispatch.wait_policy {
|
let outcome = match dispatch.wait_policy {
|
||||||
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(),
|
StartAgentWaitPolicy::Completion | StartAgentWaitPolicy::LocalStartup => {
|
||||||
|
dispatch.receiver.recv().await.ok()
|
||||||
|
}
|
||||||
StartAgentWaitPolicy::Startup => {
|
StartAgentWaitPolicy::Startup => {
|
||||||
let timeout = warpui::r#async::Timer::after(spawn_timeout);
|
let timeout = warpui::r#async::Timer::after(spawn_timeout);
|
||||||
match futures::future::select(Box::pin(dispatch.receiver.recv()), Box::pin(timeout))
|
match futures::future::select(Box::pin(dispatch.receiver.recv()), Box::pin(timeout))
|
||||||
|
|||||||
@@ -458,12 +458,12 @@ fn recovered_run_agents_reattaches_existing_child_and_launches_only_missing_chil
|
|||||||
assert_eq!(agents.len(), 2);
|
assert_eq!(agents.len(), 2);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&agents[0].kind,
|
&agents[0].kind,
|
||||||
RunAgentsAgentOutcomeKind::Completed { agent_id, .. }
|
RunAgentsAgentOutcomeKind::Launched { agent_id }
|
||||||
if agent_id == &existing_child_id.to_string()
|
if agent_id == &existing_child_id.to_string()
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
&agents[1].kind,
|
&agents[1].kind,
|
||||||
RunAgentsAgentOutcomeKind::Completed { agent_id, .. }
|
RunAgentsAgentOutcomeKind::Launched { agent_id }
|
||||||
if agent_id == &missing_child_id.to_string()
|
if agent_id == &missing_child_id.to_string()
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
@@ -698,6 +698,7 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
|
|||||||
let global_resource_handles = GlobalResourceHandles::mock(app);
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
||||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||||
|
app.add_singleton_model(OrchestrationEventService::new);
|
||||||
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
|
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
|
||||||
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
||||||
app.add_singleton_model(AgentNotificationsModel::new);
|
app.add_singleton_model(AgentNotificationsModel::new);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::ai::agent::{
|
|||||||
StartAgentExecutionMode, StartAgentResult,
|
StartAgentExecutionMode, StartAgentResult,
|
||||||
};
|
};
|
||||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||||
|
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||||
use crate::ai::llms::{LLMId, LLMPreferences};
|
use crate::ai::llms::{LLMId, LLMPreferences};
|
||||||
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
||||||
@@ -42,6 +43,8 @@ pub enum StartAgentOutcome {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum StartAgentWaitPolicy {
|
pub enum StartAgentWaitPolicy {
|
||||||
Startup,
|
Startup,
|
||||||
|
/// A local child has been linked; its persistent group owns completion.
|
||||||
|
LocalStartup,
|
||||||
Completion,
|
Completion,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +230,20 @@ impl StartAgentExecutor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(pending) = self.pending.get(&request_id) {
|
||||||
|
if matches!(pending.wait_policy, StartAgentWaitPolicy::LocalStartup) {
|
||||||
|
let parent = pending.parent_conversation_id;
|
||||||
|
let group_id = pending.action_id.to_string();
|
||||||
|
OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||||
|
service.register_local_group_child(
|
||||||
|
parent,
|
||||||
|
&group_id,
|
||||||
|
child_conversation_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(event) = child_link_event {
|
if let Some(event) = child_link_event {
|
||||||
ctx.emit(event);
|
ctx.emit(event);
|
||||||
}
|
}
|
||||||
@@ -373,6 +390,14 @@ impl StartAgentExecutor {
|
|||||||
| ConversationStatus::Blocked { .. }
|
| ConversationStatus::Blocked { .. }
|
||||||
| ConversationStatus::WaitingForEvents => {}
|
| ConversationStatus::WaitingForEvents => {}
|
||||||
},
|
},
|
||||||
|
Some(StartAgentWaitPolicy::LocalStartup) => {
|
||||||
|
// Membership is durable before launch acknowledgement reaches the parent.
|
||||||
|
if let Some(pending) = self.pending.remove(&request_id) {
|
||||||
|
let _ = pending.sender.try_send(StartAgentOutcome::Started {
|
||||||
|
agent_id: child_conversation_id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(StartAgentWaitPolicy::Startup) => {
|
Some(StartAgentWaitPolicy::Startup) => {
|
||||||
if let Some(error_msg) = start_agent_startup_error_message_for_status(
|
if let Some(error_msg) = start_agent_startup_error_message_for_status(
|
||||||
conversation.status(),
|
conversation.status(),
|
||||||
@@ -683,6 +708,15 @@ impl StartAgentExecutor {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn yield_after_local_start(&mut self, dispatch: &mut StartAgentDispatch) {
|
||||||
|
if matches!(dispatch.wait_policy, StartAgentWaitPolicy::Completion) {
|
||||||
|
dispatch.wait_policy = StartAgentWaitPolicy::LocalStartup;
|
||||||
|
if let Some(pending) = self.pending.get_mut(&dispatch.request_id) {
|
||||||
|
pending.wait_policy = StartAgentWaitPolicy::LocalStartup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch a pre-validated StartAgent request. Returns a receiver
|
/// Dispatch a pre-validated StartAgent request. Returns a receiver
|
||||||
/// for the resulting [`StartAgentOutcome`].
|
/// for the resulting [`StartAgentOutcome`].
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ fn execute_resolves_error_when_request_linkage_happens_after_child_already_faile
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||||
if error == "'codex' CLI not found on your machine."
|
if error.contains("'codex' CLI not found on your machine.") && error.contains("send_message_to_agent")
|
||||||
&& version == StartAgentVersion::V1
|
&& version == StartAgentVersion::V1
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -906,7 +906,7 @@ fn direct_provider_nonterminal_states_remain_pending_until_cancelled() {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
state.dispatch.receiver.try_recv(),
|
state.dispatch.receiver.try_recv(),
|
||||||
Ok(StartAgentOutcome::Error(error))
|
Ok(StartAgentOutcome::Error(error))
|
||||||
if error == "Child agent was cancelled by the user."
|
if error.contains("Child agent was cancelled by the user.") && error.contains("send_message_to_agent")
|
||||||
));
|
));
|
||||||
state.executor.read(&app, |executor, _| {
|
state.executor.read(&app, |executor, _| {
|
||||||
assert!(executor.pending.is_empty());
|
assert!(executor.pending.is_empty());
|
||||||
@@ -933,7 +933,7 @@ fn direct_provider_error_preserves_child_for_inspection() {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
state.dispatch.receiver.try_recv(),
|
state.dispatch.receiver.try_recv(),
|
||||||
Ok(StartAgentOutcome::Error(error)) if error == "Child execution failed"
|
Ok(StartAgentOutcome::Error(error)) if error.contains("Child execution failed") && error.contains("send_message_to_agent")
|
||||||
));
|
));
|
||||||
state.captured_cleanup.read(&app, |captured, _| {
|
state.captured_cleanup.read(&app, |captured, _| {
|
||||||
assert!(captured.0.is_empty());
|
assert!(captured.0.is_empty());
|
||||||
@@ -1369,7 +1369,7 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||||
if error == "Failed to resolve child agent skills: review-comments"
|
if error.contains("Failed to resolve child agent skills: review-comments") && error.contains("send_message_to_agent")
|
||||||
&& version == StartAgentVersion::V1
|
&& version == StartAgentVersion::V1
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
@@ -1739,7 +1739,7 @@ fn parallel_pendings_each_resolve_independently_via_recorded_child_id() {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result_b,
|
result_b,
|
||||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, .. })
|
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, .. })
|
||||||
if error == "Agent B init failed"
|
if error.contains("Agent B init failed") && error.contains("send_message_to_agent")
|
||||||
));
|
));
|
||||||
|
|
||||||
executor.read(&app, |executor, _| {
|
executor.read(&app, |executor, _| {
|
||||||
|
|||||||
@@ -3949,8 +3949,8 @@ impl BlocklistAIController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if has_piggybacked_events && result.is_ok() {
|
if has_piggybacked_events && result.is_ok() {
|
||||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, _| {
|
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||||
svc.confirm_local_messages(conversation_id);
|
svc.confirm_local_delivery(conversation_id, ctx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.pending_passive_follow_ups.remove(&conversation_id);
|
self.pending_passive_follow_ups.remove(&conversation_id);
|
||||||
@@ -4371,8 +4371,8 @@ impl BlocklistAIController {
|
|||||||
svc.requeue_awaiting_events(conversation_id, ctx);
|
svc.requeue_awaiting_events(conversation_id, ctx);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, _| {
|
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||||
svc.confirm_local_messages(conversation_id);
|
svc.confirm_local_delivery(conversation_id, ctx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7321,6 +7321,13 @@ impl BlocklistAIController {
|
|||||||
slot: &mut ActiveProviderRunSlot,
|
slot: &mut ActiveProviderRunSlot,
|
||||||
run: &mut ActiveProviderRun,
|
run: &mut ActiveProviderRun,
|
||||||
) -> Result<ProviderBoundaryDisposition, String> {
|
) -> Result<ProviderBoundaryDisposition, String> {
|
||||||
|
// A launch yield can complete the run as its tool batch commits. Once the
|
||||||
|
// action projection is committed, drive terminal cleanup rather than parking it.
|
||||||
|
if run.coordinator.run().is_terminal() && slot.committed_provider_batch.is_none() {
|
||||||
|
return Ok(ProviderBoundaryDisposition::Advance {
|
||||||
|
completed_block_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
let state = run.coordinator.run().state();
|
let state = run.coordinator.run().state();
|
||||||
let phase = provider_boundary_phase(state);
|
let phase = provider_boundary_phase(state);
|
||||||
let intent = provider_boundary_intent(
|
let intent = provider_boundary_intent(
|
||||||
@@ -8192,16 +8199,23 @@ impl BlocklistAIController {
|
|||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||||
|
let status = if OrchestrationEventService::as_ref(ctx)
|
||||||
|
.has_waiting_local_groups(conversation_id, ctx)
|
||||||
|
{
|
||||||
|
ConversationStatus::WaitingForEvents
|
||||||
|
} else {
|
||||||
|
ConversationStatus::Success
|
||||||
|
};
|
||||||
let should_finalize = history_model
|
let should_finalize = history_model
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.conversation_status(&conversation_id)
|
.conversation_status(&conversation_id)
|
||||||
.is_some_and(|status| status != &ConversationStatus::Success);
|
.is_some_and(|current| current != &status);
|
||||||
if should_finalize {
|
if should_finalize {
|
||||||
history_model.update(ctx, |history_model, ctx| {
|
history_model.update(ctx, |history_model, ctx| {
|
||||||
history_model.update_conversation_status(
|
history_model.update_conversation_status(
|
||||||
self.terminal_surface_id,
|
self.terminal_surface_id,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
ConversationStatus::Success,
|
status,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -8397,6 +8411,9 @@ impl BlocklistAIController {
|
|||||||
});
|
});
|
||||||
self.maybe_refresh_ai_overages(ctx);
|
self.maybe_refresh_ai_overages(ctx);
|
||||||
self.start_next_queued_provider_run(conversation_id, ctx);
|
self.start_next_queued_provider_run(conversation_id, ctx);
|
||||||
|
OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||||
|
service.reconcile_local_groups_for_child(conversation_id, ctx);
|
||||||
|
});
|
||||||
// Status notifications may arrive before the stream releases its slot.
|
// Status notifications may arrive before the stream releases its slot.
|
||||||
// Retry queued child messages now that terminal cleanup is complete.
|
// Retry queued child messages now that terminal cleanup is complete.
|
||||||
self.handle_pending_events_ready(conversation_id, ctx);
|
self.handle_pending_events_ready(conversation_id, ctx);
|
||||||
|
|||||||
@@ -782,6 +782,19 @@ impl BlocklistAIHistoryModel {
|
|||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
pub(crate) fn set_local_agent_groups(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
groups: Vec<persistence::model::LocalAgentGroup>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
conversation.set_local_agent_groups(groups);
|
||||||
|
self.persist_conversation_state(conversation_id, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mark_conversation_as_remote_child(
|
pub fn mark_conversation_as_remote_child(
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
@@ -1691,6 +1704,7 @@ impl BlocklistAIHistoryModel {
|
|||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: source_conversation.agent_backend().for_fork(),
|
agent_backend: source_conversation.agent_backend().for_fork(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
|
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
|
||||||
reverted_action_ids,
|
reverted_action_ids,
|
||||||
@@ -1856,6 +1870,7 @@ impl BlocklistAIHistoryModel {
|
|||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: conversation.agent_backend().for_fork(),
|
agent_backend: conversation.agent_backend().for_fork(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids,
|
reverted_action_ids,
|
||||||
@@ -2866,6 +2881,7 @@ fn merged_remote_child_placeholder_conversation_data(
|
|||||||
|
|
||||||
// Active process-local provider runs cannot be merged from a cloud transcript.
|
// Active process-local provider runs cannot be merged from a cloud transcript.
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
|
|
||||||
// Cloud authoritative.
|
// Cloud authoritative.
|
||||||
server_conversation_token: cloud_conversation
|
server_conversation_token: cloud_conversation
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
//! Local delegation membership survives parent turns; only complete groups wake the parent.
|
||||||
|
use galaxyui::AppContext;
|
||||||
|
use persistence::model::LocalAgentGroup;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn group_event_id(group: &LocalAgentGroup) -> String {
|
||||||
|
format!("local-group/{}/{}", group.id, group.revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_child(status: &ConversationStatus) -> bool {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
ConversationStatus::Success | ConversationStatus::Error | ConversationStatus::Cancelled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrchestrationEventService {
|
||||||
|
pub(crate) fn has_waiting_local_groups(
|
||||||
|
&self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
ctx: &AppContext,
|
||||||
|
) -> bool {
|
||||||
|
BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
|
.conversation(&parent)
|
||||||
|
.is_some_and(|conversation| {
|
||||||
|
conversation
|
||||||
|
.local_agent_groups()
|
||||||
|
.iter()
|
||||||
|
.any(|group| !group.notified && !group.children.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_local_group(
|
||||||
|
&mut self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
id: &str,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
self.update_local_group(parent, id, ctx, |_| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn register_local_group_child(
|
||||||
|
&mut self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
id: &str,
|
||||||
|
child: AIConversationId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
self.update_local_group(parent, id, ctx, |group| {
|
||||||
|
let child = child.to_string();
|
||||||
|
if !group.children.contains(&child) {
|
||||||
|
group.children.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn seal_local_group(
|
||||||
|
&mut self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
id: &str,
|
||||||
|
errors: Vec<String>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
self.update_local_group(parent, id, ctx, |group| {
|
||||||
|
group.sealed = true;
|
||||||
|
group.launch_errors.extend(errors);
|
||||||
|
});
|
||||||
|
self.reconcile_local_groups(parent, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_local_group(
|
||||||
|
&mut self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
id: &str,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
edit: impl FnOnce(&mut LocalAgentGroup),
|
||||||
|
) {
|
||||||
|
let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut groups = conversation.local_agent_groups().to_vec();
|
||||||
|
if !groups.iter().any(|group| group.id == id) {
|
||||||
|
groups.push(LocalAgentGroup {
|
||||||
|
id: id.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
edit(
|
||||||
|
groups
|
||||||
|
.iter_mut()
|
||||||
|
.find(|group| group.id == id)
|
||||||
|
.expect("group was inserted"),
|
||||||
|
);
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
history.set_local_agent_groups(parent, groups, ctx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn confirm_group_messages(
|
||||||
|
&mut self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
event_ids: &[String],
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut groups = conversation.local_agent_groups().to_vec();
|
||||||
|
let mut changed = false;
|
||||||
|
for group in &mut groups {
|
||||||
|
if event_ids.contains(&group_event_id(group)) && !group.notified {
|
||||||
|
group.notified = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
history.set_local_agent_groups(parent, groups, ctx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate current statuses, rather than counting completion callbacks. A restarted
|
||||||
|
/// child becomes pending again and invalidates any undelivered earlier group result.
|
||||||
|
pub(crate) fn reconcile_local_groups_for_child(
|
||||||
|
&mut self,
|
||||||
|
child: AIConversationId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> bool {
|
||||||
|
let parents = BlocklistAIHistoryModel::as_ref(ctx)
|
||||||
|
.all_live_conversations()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(_, conversation)| {
|
||||||
|
conversation
|
||||||
|
.local_agent_groups()
|
||||||
|
.iter()
|
||||||
|
.any(|group| group.children.contains(&child.to_string()))
|
||||||
|
.then_some(conversation.id())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let grouped = !parents.is_empty();
|
||||||
|
for parent in parents {
|
||||||
|
self.reconcile_local_groups(parent, ctx);
|
||||||
|
}
|
||||||
|
grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn reconcile_local_groups(
|
||||||
|
&mut self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||||
|
let Some(conversation) = history.conversation(&parent) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut groups = conversation.local_agent_groups().to_vec();
|
||||||
|
let original = groups.clone();
|
||||||
|
let mut ready = Vec::new();
|
||||||
|
let mut stale = Vec::new();
|
||||||
|
for group in &mut groups {
|
||||||
|
if group.children.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let children = group
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.map(|id| {
|
||||||
|
AIConversationId::try_from(id.clone())
|
||||||
|
.ok()
|
||||||
|
.and_then(|id| history.conversation(&id))
|
||||||
|
.filter(|child| {
|
||||||
|
child.parent_conversation_id() == Some(parent)
|
||||||
|
&& !child.is_remote_child()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let finished = children.iter().all(|child| {
|
||||||
|
child.is_none_or(|child| {
|
||||||
|
terminal_child(child.status()) && child.active_provider_run_json().is_none()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let event_id = group_event_id(group);
|
||||||
|
let queued = self
|
||||||
|
.pending_events
|
||||||
|
.get(&parent)
|
||||||
|
.into_iter()
|
||||||
|
.chain(self.awaiting_server_echo_events.get(&parent))
|
||||||
|
.flatten()
|
||||||
|
.any(|event| event.event_id == event_id);
|
||||||
|
if !finished {
|
||||||
|
if group.notified || queued {
|
||||||
|
stale.push(event_id);
|
||||||
|
group.revision = group.revision.saturating_add(1);
|
||||||
|
group.notified = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !group.sealed || group.notified || queued {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut result = format!("All children in delegation group {} have finished. Process their results together now. Failed or cancelled children are explicit blockers, not successful results. Use a corrective follow-up to the same child for recoverable errors; do not override cancellation.\n", group.id);
|
||||||
|
for (id, child) in group.children.iter().zip(children) {
|
||||||
|
let Some(child) = child else {
|
||||||
|
result.push_str(&format!("\nChild {id}: removed or unavailable.\n"));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
result.push_str(&format!(
|
||||||
|
"\n{} ({id}): {:?}\n",
|
||||||
|
child.agent_name().unwrap_or("Child"),
|
||||||
|
child.status()
|
||||||
|
));
|
||||||
|
if matches!(child.status(), ConversationStatus::Success) {
|
||||||
|
let output = child
|
||||||
|
.all_exchanges()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.filter_map(|exchange| exchange.output_status.output())
|
||||||
|
.map(|output| output.get().format_for_copy(None))
|
||||||
|
.find(|output| !output.is_empty())
|
||||||
|
.unwrap_or_default();
|
||||||
|
// Bound the whole group, including large fan-outs, to roughly 32 KB.
|
||||||
|
let limit = 32_000 / group.children.len().max(1);
|
||||||
|
let mut end = output.len().min(limit);
|
||||||
|
while !output.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
result.push_str(&output[..end]);
|
||||||
|
if end < output.len() {
|
||||||
|
result.push_str("\n[Result excerpt truncated; inspect the child conversation for more.]");
|
||||||
|
}
|
||||||
|
} else if let Some(error) = child.status_error_message() {
|
||||||
|
result.push_str(&error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for error in &group.launch_errors {
|
||||||
|
result.push_str(&format!("\nLaunch failure: {error}\n"));
|
||||||
|
}
|
||||||
|
if result.len() > 48_000 {
|
||||||
|
let mut end = 48_000;
|
||||||
|
while !result.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
result.truncate(end);
|
||||||
|
result.push_str(
|
||||||
|
"\n[Group result truncated; inspect the child conversations for full details.]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ready.push(PendingEvent {
|
||||||
|
event_id: event_id.clone(),
|
||||||
|
source_agent_id: parent.to_string(),
|
||||||
|
attempt_count: 0,
|
||||||
|
detail: PendingEventDetail::LocalMessage {
|
||||||
|
message_id: event_id,
|
||||||
|
subject: "Child agent group completed".to_string(),
|
||||||
|
message_body: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for events in [
|
||||||
|
self.pending_events.get_mut(&parent),
|
||||||
|
self.awaiting_server_echo_events.get_mut(&parent),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
events.retain(|event| !stale.contains(&event.event_id));
|
||||||
|
}
|
||||||
|
if groups != original {
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
history.set_local_agent_groups(parent, groups, ctx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !ready.is_empty() {
|
||||||
|
self.enqueue_event_batch(parent, ready, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
#[path = "local_agent_groups.rs"]
|
||||||
|
mod local_agent_groups;
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -146,6 +149,9 @@ impl OrchestrationEventService {
|
|||||||
for conversation_id in conversation_ids {
|
for conversation_id in conversation_ids {
|
||||||
self.sync_conversation_status(*conversation_id, ctx);
|
self.sync_conversation_status(*conversation_id, ctx);
|
||||||
}
|
}
|
||||||
|
for conversation_id in conversation_ids {
|
||||||
|
self.reconcile_local_groups(*conversation_id, ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BlocklistAIHistoryEvent::RemoveConversation {
|
BlocklistAIHistoryEvent::RemoveConversation {
|
||||||
conversation_id, ..
|
conversation_id, ..
|
||||||
@@ -157,6 +163,7 @@ impl OrchestrationEventService {
|
|||||||
self.awaiting_server_echo_events.remove(conversation_id);
|
self.awaiting_server_echo_events.remove(conversation_id);
|
||||||
self.conversation_statuses.remove(conversation_id);
|
self.conversation_statuses.remove(conversation_id);
|
||||||
self.restarted_children.remove(conversation_id);
|
self.restarted_children.remove(conversation_id);
|
||||||
|
self.reconcile_local_groups_for_child(*conversation_id, ctx);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -196,7 +203,10 @@ impl OrchestrationEventService {
|
|||||||
let previous = self
|
let previous = self
|
||||||
.conversation_statuses
|
.conversation_statuses
|
||||||
.insert(conversation_id, current_status.clone());
|
.insert(conversation_id, current_status.clone());
|
||||||
if !is_restored && previous.as_ref() != Some(¤t_status) {
|
if !is_restored
|
||||||
|
&& previous.as_ref() != Some(¤t_status)
|
||||||
|
&& !self.reconcile_local_groups_for_child(conversation_id, ctx)
|
||||||
|
{
|
||||||
self.report_restarted_child(conversation_id, previous.as_ref(), ¤t_status, ctx);
|
self.report_restarted_child(conversation_id, previous.as_ref(), ¤t_status, ctx);
|
||||||
}
|
}
|
||||||
let has_pending = self
|
let has_pending = self
|
||||||
@@ -231,6 +241,23 @@ impl OrchestrationEventService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Local providers commit the input directly and do not emit a hosted message echo.
|
/// Local providers commit the input directly and do not emit a hosted message echo.
|
||||||
|
pub(crate) fn confirm_local_delivery(
|
||||||
|
&mut self,
|
||||||
|
parent: AIConversationId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let ids = self
|
||||||
|
.awaiting_server_echo_events
|
||||||
|
.get(&parent)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|event| matches!(event.detail, PendingEventDetail::LocalMessage { .. }))
|
||||||
|
.map(|event| event.event_id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
self.confirm_group_messages(parent, &ids, ctx);
|
||||||
|
self.confirm_local_messages(parent);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn confirm_local_messages(&mut self, conversation_id: AIConversationId) {
|
pub fn confirm_local_messages(&mut self, conversation_id: AIConversationId) {
|
||||||
if let Some(events) = self.awaiting_server_echo_events.get_mut(&conversation_id) {
|
if let Some(events) = self.awaiting_server_echo_events.get_mut(&conversation_id) {
|
||||||
events.retain(|event| !matches!(event.detail, PendingEventDetail::LocalMessage { .. }));
|
events.retain(|event| !matches!(event.detail, PendingEventDetail::LocalMessage { .. }));
|
||||||
|
|||||||
@@ -391,3 +391,65 @@ fn local_follow_up_is_delivered_once_and_acknowledged_without_server_echo() {
|
|||||||
assert_eq!(awaiting[0].event_id, "hosted-1");
|
assert_eq!(awaiting[0].event_id, "hosted-1");
|
||||||
assert!(!service.has_pending_local_message(conversation_id));
|
assert!(!service.has_pending_local_message(conversation_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_group_waits_for_every_child_and_invalidates_results_after_restart() {
|
||||||
|
galaxyui::App::test((), |mut app| async move {
|
||||||
|
crate::test_util::settings::initialize_history_persistence_for_tests(&mut app);
|
||||||
|
let surface = galaxyui::EntityId::new();
|
||||||
|
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||||
|
let service = app.add_singleton_model(OrchestrationEventService::new);
|
||||||
|
let (parent, first, second) = history.update(&mut app, |history, ctx| {
|
||||||
|
let parent = history.start_new_conversation(surface, false, false, false, ctx);
|
||||||
|
let first =
|
||||||
|
history.start_new_child_conversation(surface, "one".to_string(), parent, None, ctx);
|
||||||
|
let second =
|
||||||
|
history.start_new_child_conversation(surface, "two".to_string(), parent, None, ctx);
|
||||||
|
(parent, first, second)
|
||||||
|
});
|
||||||
|
service.update(&mut app, |service, ctx| {
|
||||||
|
service.begin_local_group(parent, "group-1", ctx);
|
||||||
|
service.register_local_group_child(parent, "group-1", first, ctx);
|
||||||
|
service.register_local_group_child(parent, "group-1", second, ctx);
|
||||||
|
service.seal_local_group(parent, "group-1", Vec::new(), ctx);
|
||||||
|
});
|
||||||
|
history.update(&mut app, |history, ctx| {
|
||||||
|
history.update_conversation_status(surface, first, ConversationStatus::Success, ctx)
|
||||||
|
});
|
||||||
|
service.read(&app, |service, _| {
|
||||||
|
assert!(!service.has_pending_events(parent))
|
||||||
|
});
|
||||||
|
history.update(&mut app, |history, ctx| {
|
||||||
|
history.update_conversation_status(surface, second, ConversationStatus::Error, ctx)
|
||||||
|
});
|
||||||
|
let old_event = service.read(&app, |service, _| {
|
||||||
|
assert_eq!(service.pending_events[&parent].len(), 1);
|
||||||
|
service.pending_events[&parent][0].event_id.clone()
|
||||||
|
});
|
||||||
|
// A user restarts the failed child before the parent has consumed the result.
|
||||||
|
history.update(&mut app, |history, ctx| {
|
||||||
|
history.update_conversation_status(surface, second, ConversationStatus::InProgress, ctx)
|
||||||
|
});
|
||||||
|
service.read(&app, |service, _| {
|
||||||
|
assert!(!service.has_pending_events(parent))
|
||||||
|
});
|
||||||
|
history.update(&mut app, |history, ctx| {
|
||||||
|
history.update_conversation_status(surface, second, ConversationStatus::Success, ctx)
|
||||||
|
});
|
||||||
|
service.update(&mut app, |service, ctx| {
|
||||||
|
assert_eq!(service.pending_events[&parent].len(), 1);
|
||||||
|
assert_ne!(service.pending_events[&parent][0].event_id, old_event);
|
||||||
|
service.drain_and_convert_events(parent);
|
||||||
|
service.confirm_local_delivery(parent, ctx);
|
||||||
|
service.reconcile_local_groups(parent, ctx);
|
||||||
|
assert!(!service.has_pending_events(parent));
|
||||||
|
});
|
||||||
|
history.read(&app, |history, _| {
|
||||||
|
let groups = history.conversation(&parent).unwrap().local_agent_groups();
|
||||||
|
assert!(groups[0].notified);
|
||||||
|
let restored: persistence::model::LocalAgentGroup =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&groups[0]).unwrap()).unwrap();
|
||||||
|
assert_eq!(&restored, &groups[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+9
-7
@@ -2688,8 +2688,10 @@ fn get_new_agent_mode_choices(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
pub(crate) fn openai_model_context_size(
|
||||||
fn openai_model_context_size(model: &OpenAIModelConfig, provider_kind: OpenAIProviderKind) -> u32 {
|
model: &OpenAIModelConfig,
|
||||||
|
provider_kind: OpenAIProviderKind,
|
||||||
|
) -> u32 {
|
||||||
let input_limit = model
|
let input_limit = model
|
||||||
.max_input_tokens
|
.max_input_tokens
|
||||||
.unwrap_or(model.context_size)
|
.unwrap_or(model.context_size)
|
||||||
@@ -2972,9 +2974,6 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIMod
|
|||||||
let default_context_size = u32_from_json_any(model, &["context_window"])
|
let default_context_size = u32_from_json_any(model, &["context_window"])
|
||||||
.or_else(|| u32_from_json_any(model, &["max_context_window"]))
|
.or_else(|| u32_from_json_any(model, &["max_context_window"]))
|
||||||
.unwrap_or(DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE);
|
.unwrap_or(DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE);
|
||||||
let max_context_size = u32_from_json_any(model, &["max_context_window"])
|
|
||||||
.unwrap_or(default_context_size)
|
|
||||||
.max(default_context_size);
|
|
||||||
let effective_context_percent = model["effective_context_window_percent"]
|
let effective_context_percent = model["effective_context_window_percent"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.and_then(|value| u32::try_from(value).ok())
|
.and_then(|value| u32::try_from(value).ok())
|
||||||
@@ -2984,10 +2983,13 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIMod
|
|||||||
u32::try_from(u64::from(context_size) * u64::from(effective_context_percent) / 100)
|
u32::try_from(u64::from(context_size) * u64::from(effective_context_percent) / 100)
|
||||||
.unwrap_or(context_size)
|
.unwrap_or(context_size)
|
||||||
};
|
};
|
||||||
let default_context_size = Some(effective_context_size(default_context_size));
|
// Store the supported session window, not the optional long-context
|
||||||
|
// maximum. Catalog consumers must not mistake that maximum for an
|
||||||
|
// enabled subscription budget.
|
||||||
|
let context_size = effective_context_size(default_context_size);
|
||||||
|
let default_context_size = Some(context_size);
|
||||||
let max_input_tokens =
|
let max_input_tokens =
|
||||||
u32_from_json_any(model, &["max_input_tokens"]).map(effective_context_size);
|
u32_from_json_any(model, &["max_input_tokens"]).map(effective_context_size);
|
||||||
let context_size = effective_context_size(max_context_size);
|
|
||||||
|
|
||||||
let vision_supported = model["input_modalities"]
|
let vision_supported = model["input_modalities"]
|
||||||
.as_array()
|
.as_array()
|
||||||
|
|||||||
@@ -805,7 +805,7 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
|
|||||||
assert_eq!(models[0].model_id, "gpt-5.6-sol");
|
assert_eq!(models[0].model_id, "gpt-5.6-sol");
|
||||||
assert_eq!(models[0].display_name, "GPT-5.6-Sol");
|
assert_eq!(models[0].display_name, "GPT-5.6-Sol");
|
||||||
assert!(models[0].vision_supported);
|
assert!(models[0].vision_supported);
|
||||||
assert_eq!(models[0].context_size, 828_400);
|
assert_eq!(models[0].context_size, 258_400);
|
||||||
assert_eq!(models[0].max_input_tokens, None);
|
assert_eq!(models[0].max_input_tokens, None);
|
||||||
assert_eq!(models[0].default_context_size, Some(258_400));
|
assert_eq!(models[0].default_context_size, Some(258_400));
|
||||||
assert_eq!(models[0].reasoning_efforts, ["low", "xhigh", "ultra"]);
|
assert_eq!(models[0].reasoning_efforts, ["low", "xhigh", "ultra"]);
|
||||||
@@ -829,10 +829,10 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() {
|
|||||||
|
|
||||||
let fixed =
|
let fixed =
|
||||||
openai_model_context_window(&models[0], crate::settings::OpenAIProviderKind::LiteLLM);
|
openai_model_context_window(&models[0], crate::settings::OpenAIProviderKind::LiteLLM);
|
||||||
assert!(fixed.is_configurable);
|
assert!(!fixed.is_configurable);
|
||||||
assert_eq!(fixed.min, 258_400);
|
assert_eq!(fixed.min, 258_400);
|
||||||
assert_eq!(fixed.default_max, 828_400);
|
assert_eq!(fixed.default_max, 258_400);
|
||||||
assert_eq!(fixed.max, 828_400);
|
assert_eq!(fixed.max, 258_400);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1960,7 +1960,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
|||||||
},
|
},
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "run_agents".to_string(),
|
name: "run_agents".to_string(),
|
||||||
description: "Start one or more child agents that share run-wide configuration. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Omitted run-wide fields use the embedded local child runtime and the configured terminal model. After launch, call wait_for_events when you need child-agent results instead of repeating their work yourself.".to_string(),
|
description: "Start one or more child agents that share run-wide configuration. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Omitted run-wide fields use the embedded local child runtime and the configured terminal model. Local launch automatically yields this turn. Do not batch other tools with run_agents. The user can keep talking to you while children work independently. Each group sends one combined update after all children finish; wait for that update before processing their results. User follow-ups do not cancel the group, and must not cause duplicate children or repeated delegated work.".to_string(),
|
||||||
input_schema: serde_json::json!({
|
input_schema: serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ pub(crate) fn add_orchestration_model_options(
|
|||||||
required.push(serde_json::Value::String("model_id".to_string()));
|
required.push(serde_json::Value::String("model_id".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
tool.description = "Start one or more child agents. Assign each child the best-fit, cost-effective model from its required model_id choices, prioritizing capability and likelihood of success over price. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. After launch, call wait_for_events when you need child-agent results instead of repeating their work yourself.".to_string();
|
tool.description = "Start one or more child agents. Assign each child the best-fit, cost-effective model from its required model_id choices, prioritizing capability and likelihood of success over price. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Local launch automatically yields this turn. Do not batch other tools with run_agents. The user can keep talking to you while children work independently. Each group sends one combined update after all children finish; wait for that update before processing their results. User follow-ups do not cancel the group, and must not cause duplicate children or repeated delegated work.".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ use crate::ai::agent::{
|
|||||||
AskUserQuestionOption, AskUserQuestionType, CreateDocumentsRequest, DocumentDiff,
|
AskUserQuestionOption, AskUserQuestionType, CreateDocumentsRequest, DocumentDiff,
|
||||||
DocumentToCreate, EditDocumentsRequest, FileEdit, FileLocations, ReadDocumentsRequest,
|
DocumentToCreate, EditDocumentsRequest, FileEdit, FileLocations, ReadDocumentsRequest,
|
||||||
ReadFilesRequest, ReadSkillRequest, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
|
ReadFilesRequest, ReadSkillRequest, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
|
||||||
RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay, StartAgentExecutionMode,
|
RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay,
|
||||||
StartAgentVersion,
|
|
||||||
};
|
};
|
||||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||||
|
|
||||||
@@ -188,13 +187,23 @@ pub(crate) fn action_from_tool_call(
|
|||||||
plan_id: optional_string(input, "plan_id")?.unwrap_or_default(),
|
plan_id: optional_string(input, "plan_id")?.unwrap_or_default(),
|
||||||
harness_auth_secret_name: None,
|
harness_auth_secret_name: None,
|
||||||
}),
|
}),
|
||||||
"start_agent" => AIAgentActionType::StartAgent {
|
// Direct-provider single-child delegation uses the same durable group lifecycle.
|
||||||
version: StartAgentVersion::V1,
|
"start_agent" => AIAgentActionType::RunAgents(RunAgentsRequest {
|
||||||
name: required_nonempty_string(input, "name")?,
|
summary: required_nonempty_string(input, "name")?,
|
||||||
prompt: required_nonempty_string(input, "prompt")?,
|
base_prompt: String::new(),
|
||||||
execution_mode: StartAgentExecutionMode::local_with_defaults(),
|
skills: Vec::new(),
|
||||||
lifecycle_subscription: None,
|
model_id: String::new(),
|
||||||
},
|
harness_type: String::new(),
|
||||||
|
execution_mode: RunAgentsExecutionMode::Local,
|
||||||
|
agent_run_configs: vec![RunAgentsAgentRunConfig {
|
||||||
|
name: required_nonempty_string(input, "name")?,
|
||||||
|
prompt: required_nonempty_string(input, "prompt")?,
|
||||||
|
title: String::new(),
|
||||||
|
model_id: String::new(),
|
||||||
|
}],
|
||||||
|
plan_id: String::new(),
|
||||||
|
harness_auth_secret_name: None,
|
||||||
|
}),
|
||||||
"send_message_to_agent" => AIAgentActionType::SendMessageToAgent {
|
"send_message_to_agent" => AIAgentActionType::SendMessageToAgent {
|
||||||
addresses: vec![required_string(input, "agent_id")?],
|
addresses: vec![required_string(input, "agent_id")?],
|
||||||
subject: String::new(),
|
subject: String::new(),
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ fn persisted_remote_child_conversation(
|
|||||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: Some("restored-child-token".to_string()),
|
server_conversation_token: Some("restored-child-token".to_string()),
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ use crate::ai::execution_profiles::{
|
|||||||
ActionPermission, WriteToPtyPermission,
|
ActionPermission, WriteToPtyPermission,
|
||||||
};
|
};
|
||||||
use crate::ai::llms::{
|
use crate::ai::llms::{
|
||||||
is_using_api_key_for_provider, LLMContextWindow, LLMId, LLMPreferences, LLMPreferencesEvent,
|
is_using_api_key_for_provider, openai_model_context_size, LLMContextWindow, LLMId,
|
||||||
LLMProvider,
|
LLMPreferences, LLMPreferencesEvent, LLMProvider,
|
||||||
};
|
};
|
||||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||||
use crate::ai::paths::host_native_absolute_path;
|
use crate::ai::paths::host_native_absolute_path;
|
||||||
@@ -7236,13 +7236,26 @@ impl ProviderSettingsWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_openai_model_details(model: &OpenAIModelConfig) -> String {
|
fn format_openai_model_details(
|
||||||
|
model: &OpenAIModelConfig,
|
||||||
|
provider: &OpenAIProviderConfig,
|
||||||
|
) -> String {
|
||||||
|
let context_size = if provider.kind == OpenAIProviderKind::ChatGPTSubscription {
|
||||||
|
// Use the same limit as requests, including for older saved catalogs
|
||||||
|
// that still contain the optional long-context maximum.
|
||||||
|
openai_model_context_size(model, provider.kind)
|
||||||
|
} else {
|
||||||
|
model.context_size
|
||||||
|
};
|
||||||
let mut details = vec![
|
let mut details = vec![
|
||||||
if model.enabled { "Enabled" } else { "Disabled" }.to_string(),
|
if model.enabled { "Enabled" } else { "Disabled" }.to_string(),
|
||||||
format!("Context: {}", model.context_size),
|
format!("Context: {context_size}"),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Some(default_context_size) = model.default_context_size {
|
if let Some(default_context_size) = model
|
||||||
|
.default_context_size
|
||||||
|
.filter(|_| provider.kind != OpenAIProviderKind::ChatGPTSubscription)
|
||||||
|
{
|
||||||
details.push(format!("Default context: {default_context_size}"));
|
details.push(format!("Default context: {default_context_size}"));
|
||||||
}
|
}
|
||||||
if let Some(max_input_tokens) = model.max_input_tokens {
|
if let Some(max_input_tokens) = model.max_input_tokens {
|
||||||
@@ -7338,7 +7351,7 @@ impl ProviderSettingsWidget {
|
|||||||
Self::render_model_row(
|
Self::render_model_row(
|
||||||
model.display_name.clone(),
|
model.display_name.clone(),
|
||||||
model.model_id.clone(),
|
model.model_id.clone(),
|
||||||
Self::format_openai_model_details(model),
|
Self::format_openai_model_details(model, provider),
|
||||||
appearance,
|
appearance,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -944,6 +944,7 @@ impl TerminalView {
|
|||||||
let conversation_data = AgentConversationData {
|
let conversation_data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
|
|||||||
@@ -1766,7 +1766,7 @@ impl RunAgentsResult {
|
|||||||
"instruction": if children_completed {
|
"instruction": if children_completed {
|
||||||
"Child agents reached terminal states. Use their structured outputs and errors to complete the task."
|
"Child agents reached terminal states. Use their structured outputs and errors to complete the task."
|
||||||
} else {
|
} 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,
|
"model_id": model_id,
|
||||||
"harness_type": harness_type,
|
"harness_type": harness_type,
|
||||||
|
|||||||
@@ -1261,9 +1261,26 @@ impl ProviderRun {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
let next_epoch = self.next_epoch()?;
|
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.transcript.push(tool_result_message(results));
|
||||||
self.epoch = next_epoch;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1382,12 +1399,25 @@ impl ProviderRun {
|
|||||||
|
|
||||||
fn build_tool_batch(&self, turn: &CompletedModelTurn) -> PendingToolBatch {
|
fn build_tool_batch(&self, turn: &CompletedModelTurn) -> PendingToolBatch {
|
||||||
let policy = ToolPolicy::from_names(turn.advertised_tools.iter().cloned());
|
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
|
let calls = turn
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|call| {
|
.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 {
|
PendingToolCallState::Resolved {
|
||||||
result: ToolResult {
|
result: ToolResult {
|
||||||
call_id: call.id.clone(),
|
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
|
&& 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> {
|
fn validate_model_turn(turn: &CompletedModelTurn) -> Result<(), ProviderRunProtocolError> {
|
||||||
for part in &turn.assistant_content {
|
for part in &turn.assistant_content {
|
||||||
match part {
|
match part {
|
||||||
|
|||||||
@@ -1206,20 +1206,20 @@ fn restore_normalization_preserves_selected_executing_tools_for_recovery() {
|
|||||||
&mut run,
|
&mut run,
|
||||||
tool_turn(
|
tool_turn(
|
||||||
vec![
|
vec![
|
||||||
tool_call("run-agents", "run_agents"),
|
tool_call("command", "run_command"),
|
||||||
tool_call("read", "read_files"),
|
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();
|
run.start_tool(&batch.work_id, "read").unwrap();
|
||||||
|
|
||||||
let normalization = run
|
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();
|
.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"]);
|
assert_eq!(normalization.interrupted_call_ids, vec!["read"]);
|
||||||
let ProviderRunState::AwaitingTools { batch } = run.state() else {
|
let ProviderRunState::AwaitingTools { batch } = run.state() else {
|
||||||
panic!("recovered tool batch should remain pending");
|
panic!("recovered tool batch should remain pending");
|
||||||
@@ -1252,7 +1252,7 @@ fn recovery_pending_tool_survives_another_restore_and_completes_once() {
|
|||||||
restored
|
restored
|
||||||
.complete_tool(
|
.complete_tool(
|
||||||
&batch.work_id,
|
&batch.work_id,
|
||||||
successful_result("run-agents", "children completed"),
|
successful_result("run-agents", "children launched"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -1263,7 +1263,7 @@ fn recovery_pending_tool_survives_another_restore_and_completes_once() {
|
|||||||
Err(ProviderRunProtocolError::DuplicateToolUpdate { .. })
|
Err(ProviderRunProtocolError::DuplicateToolUpdate { .. })
|
||||||
));
|
));
|
||||||
restored.commit_tool_batch(&batch.work_id).unwrap();
|
restored.commit_tool_batch(&batch.work_id).unwrap();
|
||||||
assert_eq!(restored.state().phase(), ProviderRunPhase::ReadyToCallModel);
|
assert_eq!(restored.state().phase(), ProviderRunPhase::Done);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1465,3 +1465,58 @@ fn multipart_archive_is_not_multiplied_and_legacy_duplicates_are_repaired() {
|
|||||||
archive_tool_results(&mut archive, transcript);
|
archive_tool_results(&mut archive, transcript);
|
||||||
assert_eq!(archive, expected);
|
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`.
|
// 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)]
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
pub struct AgentConversationData {
|
pub struct AgentConversationData {
|
||||||
#[serde(default, skip_serializing_if = "AgentBackend::is_provider")]
|
#[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.
|
/// Versioned application-owned snapshot for an active direct-provider run.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub active_provider_run_json: Option<String>,
|
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>,
|
pub server_conversation_token: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub conversation_usage_metadata: Option<ConversationUsageMetadata>,
|
pub conversation_usage_metadata: Option<ConversationUsageMetadata>,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ fn agent_conversation_data_roundtrips_last_event_sequence() {
|
|||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
@@ -241,6 +242,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() {
|
|||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
@@ -269,6 +271,7 @@ fn agent_conversation_data_roundtrips_optimistic_root_marker() {
|
|||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
@@ -309,6 +312,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
|||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
@@ -339,6 +343,7 @@ fn agent_conversation_data_roundtrips_pinned() {
|
|||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
@@ -367,6 +372,7 @@ fn agent_conversation_data_skips_serializing_unpinned() {
|
|||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: Default::default(),
|
agent_backend: Default::default(),
|
||||||
active_provider_run_json: None,
|
active_provider_run_json: None,
|
||||||
|
local_agent_groups: Vec::new(),
|
||||||
server_conversation_token: None,
|
server_conversation_token: None,
|
||||||
conversation_usage_metadata: None,
|
conversation_usage_metadata: None,
|
||||||
reverted_action_ids: None,
|
reverted_action_ids: None,
|
||||||
|
|||||||
Vendored
+2
-57
@@ -340,60 +340,5 @@ impl<B> BlockDescriptor<B> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "lib_tests.rs"]
|
||||||
use test_utils::*;
|
mod tests;
|
||||||
use super::{ConcreteBlock, RcBlock};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_call_block() {
|
|
||||||
let block = get_int_block_with(13);
|
|
||||||
unsafe {
|
|
||||||
assert!(block.call(()) == 13);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_call_block_args() {
|
|
||||||
let block = get_add_block_with(13);
|
|
||||||
unsafe {
|
|
||||||
assert!(block.call((2,)) == 15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_create_block() {
|
|
||||||
let block = ConcreteBlock::new(|| 13);
|
|
||||||
let result = invoke_int_block(&block);
|
|
||||||
assert!(result == 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_create_block_args() {
|
|
||||||
let block = ConcreteBlock::new(|a: i32| a + 5);
|
|
||||||
let result = invoke_add_block(&block, 6);
|
|
||||||
assert!(result == 11);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_concrete_block_copy() {
|
|
||||||
let s = "Hello!".to_string();
|
|
||||||
let expected_len = s.len() as i32;
|
|
||||||
let block = ConcreteBlock::new(move || s.len() as i32);
|
|
||||||
assert!(invoke_int_block(&block) == expected_len);
|
|
||||||
|
|
||||||
let copied = block.copy();
|
|
||||||
assert!(invoke_int_block(&copied) == expected_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_concrete_block_stack_copy() {
|
|
||||||
fn make_block() -> RcBlock<(), i32> {
|
|
||||||
let x = 7;
|
|
||||||
let block = ConcreteBlock::new(move || x);
|
|
||||||
block.copy()
|
|
||||||
}
|
|
||||||
|
|
||||||
let block = make_block();
|
|
||||||
assert!(invoke_int_block(&block) == 7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+55
@@ -0,0 +1,55 @@
|
|||||||
|
use test_utils::*;
|
||||||
|
use super::{ConcreteBlock, RcBlock};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_block() {
|
||||||
|
let block = get_int_block_with(13);
|
||||||
|
unsafe {
|
||||||
|
assert!(block.call(()) == 13);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_call_block_args() {
|
||||||
|
let block = get_add_block_with(13);
|
||||||
|
unsafe {
|
||||||
|
assert!(block.call((2,)) == 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_block() {
|
||||||
|
let block = ConcreteBlock::new(|| 13);
|
||||||
|
let result = invoke_int_block(&block);
|
||||||
|
assert!(result == 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_block_args() {
|
||||||
|
let block = ConcreteBlock::new(|a: i32| a + 5);
|
||||||
|
let result = invoke_add_block(&block, 6);
|
||||||
|
assert!(result == 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concrete_block_copy() {
|
||||||
|
let s = "Hello!".to_string();
|
||||||
|
let expected_len = s.len() as i32;
|
||||||
|
let block = ConcreteBlock::new(move || s.len() as i32);
|
||||||
|
assert!(invoke_int_block(&block) == expected_len);
|
||||||
|
|
||||||
|
let copied = block.copy();
|
||||||
|
assert!(invoke_int_block(&copied) == expected_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concrete_block_stack_copy() {
|
||||||
|
fn make_block() -> RcBlock<(), i32> {
|
||||||
|
let x = 7;
|
||||||
|
let block = ConcreteBlock::new(move || x);
|
||||||
|
block.copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = make_block();
|
||||||
|
assert!(invoke_int_block(&block) == 7);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user