Wait for direct-provider child agents
This commit is contained in:
@@ -7,14 +7,15 @@ use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||
use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult};
|
||||
use ai::agent::action_result::{RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsResult};
|
||||
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus};
|
||||
use ai::skills::SkillReference;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warpui::elements::{
|
||||
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, Text, Wrap,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||
Stack, Text, Wrap,
|
||||
};
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::{
|
||||
@@ -22,12 +23,16 @@ use warpui::{
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus, StatusColorStyle};
|
||||
use crate::ai::agent::{icons, AIAgentActionId, AIAgentActionResultType};
|
||||
use crate::ai::blocklist::action_model::{
|
||||
AIActionStatus, BlocklistAIActionEvent, BlocklistAIActionModel, RunAgentsExecutor,
|
||||
RunAgentsExecutorEvent, RunAgentsSpawningSnapshot,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::orchestration_conversation_links::{
|
||||
conversation_id_for_agent_id, conversation_navigation_card_with_icon,
|
||||
dispatch_focus_or_open_child_agent_pane,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::orchestration_pill_bar::render_static_agent_pill;
|
||||
use crate::ai::blocklist::block::model::AIBlockModel;
|
||||
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
|
||||
@@ -50,6 +55,7 @@ use crate::ai::blocklist::telemetry::{
|
||||
OrchestrationExecutionModeKind, OrchestrationHarnessKind, RunAgentsCardDecision,
|
||||
RunAgentsCardDecisionEvent,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::connected_self_hosted_workers::{
|
||||
ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel,
|
||||
};
|
||||
@@ -213,11 +219,90 @@ pub enum RunAgentsCardViewEvent {
|
||||
RejectRequested,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RunAgentsChildState {
|
||||
name: String,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
removed: bool,
|
||||
mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl RunAgentsChildState {
|
||||
fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
conversation_id: None,
|
||||
removed: false,
|
||||
mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_run_agents_children(
|
||||
children: &mut Vec<RunAgentsChildState>,
|
||||
configs: &[RunAgentsAgentRunConfig],
|
||||
) {
|
||||
let mut previous_children = std::mem::take(children);
|
||||
*children = configs
|
||||
.iter()
|
||||
.map(|config| {
|
||||
previous_children
|
||||
.iter()
|
||||
.position(|child| child.name == config.name)
|
||||
.map(|index| previous_children.remove(index))
|
||||
.unwrap_or_else(|| RunAgentsChildState::new(config.name.clone()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn link_run_agents_child(
|
||||
children: &mut [RunAgentsChildState],
|
||||
agent_name: &str,
|
||||
conversation_id: AIConversationId,
|
||||
) -> bool {
|
||||
let child_index = children
|
||||
.iter()
|
||||
.position(|child| child.name == agent_name && child.conversation_id.is_none())
|
||||
.or_else(|| children.iter().position(|child| child.name == agent_name));
|
||||
let Some(child_index) = child_index else {
|
||||
return false;
|
||||
};
|
||||
let child = &mut children[child_index];
|
||||
child.conversation_id = Some(conversation_id);
|
||||
child.removed = false;
|
||||
true
|
||||
}
|
||||
|
||||
fn has_run_agents_child(
|
||||
children: &[RunAgentsChildState],
|
||||
conversation_id: AIConversationId,
|
||||
) -> bool {
|
||||
children
|
||||
.iter()
|
||||
.any(|child| child.conversation_id == Some(conversation_id))
|
||||
}
|
||||
|
||||
fn mark_run_agents_child_removed(
|
||||
children: &mut [RunAgentsChildState],
|
||||
conversation_id: AIConversationId,
|
||||
) -> bool {
|
||||
let Some(child) = children
|
||||
.iter_mut()
|
||||
.find(|child| child.conversation_id == Some(conversation_id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
child.removed = true;
|
||||
true
|
||||
}
|
||||
|
||||
pub struct RunAgentsCardView {
|
||||
action_id: AIAgentActionId,
|
||||
state: RunAgentsEditState,
|
||||
handles: RunAgentsCardHandles,
|
||||
spawning: Option<RunAgentsSpawningSnapshot>,
|
||||
children: Vec<RunAgentsChildState>,
|
||||
terminal_view_id: warpui::EntityId,
|
||||
/// Retained for interactive defaults and telemetry about plan-sourced
|
||||
/// orchestration state.
|
||||
active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>,
|
||||
@@ -303,6 +388,12 @@ impl RunAgentsCardView {
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let state = RunAgentsEditState::from_request(request);
|
||||
let children = state
|
||||
.agent_run_configs
|
||||
.iter()
|
||||
.map(|config| RunAgentsChildState::new(config.name.clone()))
|
||||
.collect();
|
||||
let terminal_view_id = run_agents_executor.as_ref(ctx).terminal_view_id();
|
||||
// Snapshot the raw incoming request so we can diff against the
|
||||
// edited state at Accept time.
|
||||
let original_tool_call_request = request.clone();
|
||||
@@ -364,8 +455,36 @@ impl RunAgentsCardView {
|
||||
me.spawning = None;
|
||||
ctx.notify();
|
||||
}
|
||||
RunAgentsExecutorEvent::ChildConversationCreated {
|
||||
action_id,
|
||||
agent_name,
|
||||
child_conversation_id,
|
||||
..
|
||||
} if action_id == &action_id_for_subscription => {
|
||||
me.link_child_conversation(agent_name, *child_conversation_id);
|
||||
ctx.notify();
|
||||
}
|
||||
RunAgentsExecutorEvent::SpawningStarted { .. }
|
||||
| RunAgentsExecutorEvent::SpawningFinished { .. } => {}
|
||||
| RunAgentsExecutorEvent::SpawningFinished { .. }
|
||||
| RunAgentsExecutorEvent::ChildConversationCreated { .. } => {}
|
||||
});
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, |me, _, event, ctx| match event {
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
conversation_id, ..
|
||||
} if me.has_child_conversation(*conversation_id) => {
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIHistoryEvent::RemoveConversation {
|
||||
conversation_id, ..
|
||||
}
|
||||
| BlocklistAIHistoryEvent::DeletedConversation {
|
||||
conversation_id, ..
|
||||
} if me.mark_child_removed(*conversation_id) => {
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Re-render when this action finishes or becomes blocked.
|
||||
@@ -481,6 +600,8 @@ impl RunAgentsCardView {
|
||||
..Default::default()
|
||||
},
|
||||
spawning: None,
|
||||
children,
|
||||
terminal_view_id,
|
||||
active_config,
|
||||
is_accept_menu_open: false,
|
||||
accept_menu,
|
||||
@@ -543,6 +664,7 @@ impl RunAgentsCardView {
|
||||
|| self.state.orch.model_id != new_state.orch.model_id
|
||||
|| self.state.orch.execution_mode != new_state.orch.execution_mode;
|
||||
self.state = new_state;
|
||||
self.sync_configured_children();
|
||||
if harness_or_model_changed {
|
||||
// Repopulate pickers and re-arm auto-open for the newly-
|
||||
// streamed harness.
|
||||
@@ -555,6 +677,26 @@ impl RunAgentsCardView {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_configured_children(&mut self) {
|
||||
sync_run_agents_children(&mut self.children, &self.state.agent_run_configs);
|
||||
}
|
||||
|
||||
fn link_child_conversation(&mut self, agent_name: &str, conversation_id: AIConversationId) {
|
||||
if !link_run_agents_child(&mut self.children, agent_name, conversation_id) {
|
||||
log::warn!(
|
||||
"RunAgentsCardView: received child conversation for unknown agent '{agent_name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn has_child_conversation(&self, conversation_id: AIConversationId) -> bool {
|
||||
has_run_agents_child(&self.children, conversation_id)
|
||||
}
|
||||
|
||||
fn mark_child_removed(&mut self, conversation_id: AIConversationId) -> bool {
|
||||
mark_run_agents_child_removed(&mut self.children, conversation_id)
|
||||
}
|
||||
|
||||
/// Validates and dispatches the resolved request.
|
||||
pub fn accept(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.handle_accept(ctx);
|
||||
@@ -957,7 +1099,13 @@ impl View for RunAgentsCardView {
|
||||
|
||||
if let Some(AIActionStatus::Finished(result)) = &status {
|
||||
if let AIAgentActionResultType::RunAgents(orchestrate_result) = &result.result {
|
||||
return render_terminal_state(orchestrate_result, appearance, app);
|
||||
return render_terminal_state(
|
||||
orchestrate_result,
|
||||
&self.children,
|
||||
self.terminal_view_id,
|
||||
appearance,
|
||||
app,
|
||||
);
|
||||
}
|
||||
log::error!(
|
||||
"Unexpected action result type for orchestrate: {:?}",
|
||||
@@ -969,13 +1117,25 @@ impl View for RunAgentsCardView {
|
||||
// In-flight dispatch: check both spawning snapshot and action
|
||||
// status because the event arrives one tick after the status.
|
||||
if let Some(snapshot) = &self.spawning {
|
||||
return render_spawning_card(snapshot, appearance, app);
|
||||
return render_spawning_card(
|
||||
snapshot,
|
||||
&self.children,
|
||||
self.terminal_view_id,
|
||||
appearance,
|
||||
app,
|
||||
);
|
||||
}
|
||||
if matches!(status, Some(AIActionStatus::RunningAsync)) {
|
||||
let snapshot = RunAgentsSpawningSnapshot {
|
||||
agent_count: self.state.agent_run_configs.len(),
|
||||
};
|
||||
return render_spawning_card(&snapshot, appearance, app);
|
||||
return render_spawning_card(
|
||||
&snapshot,
|
||||
&self.children,
|
||||
self.terminal_view_id,
|
||||
appearance,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
// Restored-from-history: dispatch state is lost, render as
|
||||
@@ -1352,11 +1512,21 @@ fn render_agents_section(state: &RunAgentsEditState, app: &AppContext) -> Box<dy
|
||||
|
||||
fn render_terminal_state(
|
||||
result: &RunAgentsResult,
|
||||
children: &[RunAgentsChildState],
|
||||
terminal_view_id: warpui::EntityId,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let (label, kind) = format_terminal_state(result);
|
||||
render_status_only_card(label, appearance, kind, app)
|
||||
render_status_card(
|
||||
label,
|
||||
appearance,
|
||||
kind,
|
||||
children,
|
||||
Some(result),
|
||||
Some(terminal_view_id),
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, StatusKind) {
|
||||
@@ -1424,6 +1594,8 @@ pub(crate) enum StatusKind {
|
||||
|
||||
fn render_spawning_card(
|
||||
snapshot: &RunAgentsSpawningSnapshot,
|
||||
children: &[RunAgentsChildState],
|
||||
terminal_view_id: warpui::EntityId,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
@@ -1433,7 +1605,15 @@ fn render_spawning_card(
|
||||
} else {
|
||||
format!("Spawning {total} agents\u{2026}")
|
||||
};
|
||||
render_status_only_card(label, appearance, StatusKind::Spawning, app)
|
||||
render_status_card(
|
||||
label,
|
||||
appearance,
|
||||
StatusKind::Spawning,
|
||||
children,
|
||||
None,
|
||||
Some(terminal_view_id),
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_status_only_card(
|
||||
@@ -1441,6 +1621,19 @@ fn render_status_only_card(
|
||||
appearance: &Appearance,
|
||||
kind: StatusKind,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
render_status_card(label, appearance, kind, &[], None, None, app)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_status_card(
|
||||
label: String,
|
||||
appearance: &Appearance,
|
||||
kind: StatusKind,
|
||||
children: &[RunAgentsChildState],
|
||||
result: Option<&RunAgentsResult>,
|
||||
terminal_view_id: Option<warpui::EntityId>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let icon = match kind {
|
||||
@@ -1452,7 +1645,8 @@ fn render_status_only_card(
|
||||
StatusKind::Failure => inline_action_icons::red_x_icon(appearance).finish(),
|
||||
StatusKind::Cancelled => inline_action_icons::cancelled_icon(appearance).finish(),
|
||||
};
|
||||
let row = render_requested_action_row_for_text(
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
column.add_child(render_requested_action_row_for_text(
|
||||
label.into(),
|
||||
appearance.ui_font_family(),
|
||||
Some(icon),
|
||||
@@ -1460,8 +1654,49 @@ fn render_status_only_card(
|
||||
false,
|
||||
false,
|
||||
app,
|
||||
);
|
||||
Container::new(row)
|
||||
));
|
||||
|
||||
if !children.is_empty() {
|
||||
let Some(terminal_view_id) = terminal_view_id else {
|
||||
log::error!("RunAgentsCardView: child rows require a terminal view id");
|
||||
return Empty::new().finish();
|
||||
};
|
||||
let outcomes = match result {
|
||||
Some(RunAgentsResult::Launched { agents, .. }) => Some(agents.as_slice()),
|
||||
Some(
|
||||
RunAgentsResult::Denied { .. }
|
||||
| RunAgentsResult::Failure { .. }
|
||||
| RunAgentsResult::Cancelled,
|
||||
)
|
||||
| None => None,
|
||||
};
|
||||
let mut child_column =
|
||||
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
for (index, child) in children.iter().enumerate() {
|
||||
let outcome = outcomes.and_then(|agents| agents.get(index));
|
||||
child_column.add_child(
|
||||
Container::new(render_run_agents_child_row(
|
||||
child,
|
||||
outcome,
|
||||
result.is_some(),
|
||||
terminal_view_id,
|
||||
appearance,
|
||||
app,
|
||||
))
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
column.add_child(
|
||||
Container::new(child_column.finish())
|
||||
.with_padding_left(8.)
|
||||
.with_padding_right(8.)
|
||||
.with_padding_bottom(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_background_color(blended_colors::neutral_2(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
@@ -1469,6 +1704,89 @@ fn render_status_only_card(
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_run_agents_child_row(
|
||||
child: &RunAgentsChildState,
|
||||
outcome: Option<&RunAgentsAgentOutcome>,
|
||||
is_terminal: bool,
|
||||
terminal_view_id: warpui::EntityId,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let outcome_conversation_id = outcome.and_then(|outcome| match &outcome.kind {
|
||||
RunAgentsAgentOutcomeKind::Launched { agent_id } => {
|
||||
conversation_id_for_agent_id(agent_id, app)
|
||||
}
|
||||
RunAgentsAgentOutcomeKind::Failed { .. } => None,
|
||||
});
|
||||
let conversation_id = child.conversation_id.or(outcome_conversation_id);
|
||||
|
||||
if !child.removed {
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
if let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)
|
||||
{
|
||||
let status = conversation.status();
|
||||
let status_icon =
|
||||
status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard);
|
||||
let mouse_state = child.mouse_state.clone();
|
||||
return conversation_navigation_card_with_icon(
|
||||
Some(status_icon),
|
||||
child.name.clone(),
|
||||
Some(status.to_string()),
|
||||
move |ctx, app, _| {
|
||||
dispatch_focus_or_open_child_agent_pane(
|
||||
conversation_id,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
},
|
||||
mouse_state,
|
||||
true,
|
||||
None,
|
||||
app,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (status, label) = if child.removed {
|
||||
(ConversationStatus::Cancelled, "Removed".to_string())
|
||||
} else if let Some(outcome) = outcome {
|
||||
match &outcome.kind {
|
||||
RunAgentsAgentOutcomeKind::Launched { .. } => {
|
||||
(ConversationStatus::Success, "Started".to_string())
|
||||
}
|
||||
RunAgentsAgentOutcomeKind::Failed { error } => (
|
||||
ConversationStatus::Error,
|
||||
if error.trim().is_empty() {
|
||||
"Failed".to_string()
|
||||
} else {
|
||||
format!("Failed: {error}")
|
||||
},
|
||||
),
|
||||
}
|
||||
} else if is_terminal {
|
||||
(ConversationStatus::Cancelled, "Not started".to_string())
|
||||
} else {
|
||||
(
|
||||
ConversationStatus::InProgress,
|
||||
"Starting\u{2026}".to_string(),
|
||||
)
|
||||
};
|
||||
let (icon, color) =
|
||||
status.status_icon_and_color(appearance.theme(), StatusColorStyle::Standard);
|
||||
render_requested_action_row_for_text(
|
||||
format!("{}: {label}", child.name).into(),
|
||||
appearance.ui_font_family(),
|
||||
Some(icon.to_warpui_icon(color.into()).finish()),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_editor(
|
||||
state: &RunAgentsEditState,
|
||||
handles: &RunAgentsCardHandles,
|
||||
|
||||
@@ -8,7 +8,11 @@ use ai::agent::action_result::{
|
||||
use ai::skills::SkillReference;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
|
||||
use super::RunAgentsEditState;
|
||||
use super::{
|
||||
has_run_agents_child, link_run_agents_child, mark_run_agents_child_removed,
|
||||
sync_run_agents_children, RunAgentsChildState, RunAgentsEditState,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
|
||||
|
||||
fn make_request(harness: &str, mode: RunAgentsExecutionMode) -> RunAgentsRequest {
|
||||
@@ -250,6 +254,57 @@ fn to_request_preserves_fields_but_normalizes_execution_to_local() {
|
||||
assert_eq!(round_tripped.plan_id, req.plan_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_child_links_and_removal_survive_streaming_config_sync() {
|
||||
let first_id = AIConversationId::new();
|
||||
let replacement_id = AIConversationId::new();
|
||||
let mut children = vec![
|
||||
RunAgentsChildState::new("alpha".to_string()),
|
||||
RunAgentsChildState::new("beta".to_string()),
|
||||
];
|
||||
|
||||
assert!(link_run_agents_child(&mut children, "alpha", first_id));
|
||||
assert!(has_run_agents_child(&children, first_id));
|
||||
assert!(mark_run_agents_child_removed(&mut children, first_id));
|
||||
assert!(children[0].removed);
|
||||
|
||||
let configs = vec![
|
||||
RunAgentsAgentRunConfig {
|
||||
name: "gamma".to_string(),
|
||||
prompt: "new work".to_string(),
|
||||
title: String::new(),
|
||||
},
|
||||
RunAgentsAgentRunConfig {
|
||||
name: "alpha".to_string(),
|
||||
prompt: "updated work".to_string(),
|
||||
title: String::new(),
|
||||
},
|
||||
];
|
||||
sync_run_agents_children(&mut children, &configs);
|
||||
|
||||
assert_eq!(
|
||||
children
|
||||
.iter()
|
||||
.map(|child| child.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["gamma", "alpha"]
|
||||
);
|
||||
assert_eq!(children[1].conversation_id, Some(first_id));
|
||||
assert!(children[1].removed);
|
||||
assert!(link_run_agents_child(
|
||||
&mut children,
|
||||
"alpha",
|
||||
replacement_id
|
||||
));
|
||||
assert_eq!(children[1].conversation_id, Some(replacement_id));
|
||||
assert!(!children[1].removed);
|
||||
assert!(!link_run_agents_child(
|
||||
&mut children,
|
||||
"missing",
|
||||
AIConversationId::new()
|
||||
));
|
||||
}
|
||||
|
||||
mod format_terminal_state_tests {
|
||||
use super::super::{format_terminal_state, StatusKind};
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user