first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,21 +1,17 @@
|
||||
use super::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use super::telemetry::{
|
||||
BlocklistOrchestrationTelemetryEvent, TeamAgentCommunicationFailedEvent,
|
||||
TeamAgentCommunicationFailureReason, TeamAgentCommunicationKind,
|
||||
TeamAgentCommunicationTransport, TeamAgentOrchestrationVersion,
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use warp_multi_agent_api as api;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::history_model::{
|
||||
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatusUpdate,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
conversation::{AIConversationId, ConversationStatus},
|
||||
task::TaskId,
|
||||
AIAgentExchangeId, AIAgentInput, AIAgentOutputMessageType, LifecycleEventType,
|
||||
ReceivedMessageInput,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
const MAX_RETRY_ATTEMPTS: i32 = 3;
|
||||
const MAX_PENDING_LIFECYCLE_EVENTS_PER_TARGET: usize = 200;
|
||||
@@ -25,16 +21,9 @@ const MAX_SUBAGENT_QUESTION_DEPTH: u8 = 3;
|
||||
/// This keeps persisted/runtime metadata consistent across API payloads and DB rows.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum LifecycleEventDetailStage {
|
||||
Startup,
|
||||
Runtime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LifecycleSubscriptionRoute {
|
||||
target_agent_id: String,
|
||||
subscribed_event_types: Option<Vec<LifecycleEventType>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(super) struct LifecycleEventDetailPayload {
|
||||
pub(crate) stage: Option<LifecycleEventDetailStage>,
|
||||
@@ -47,13 +36,12 @@ impl LifecycleEventDetailStage {
|
||||
/// Canonical lowercase representation used in persistence/API payloads.
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Startup => "startup",
|
||||
Self::Runtime => "runtime",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type-specific queued data, including service-generated fields.
|
||||
/// Type-specific queued data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PendingEventDetail {
|
||||
Message {
|
||||
@@ -88,37 +76,23 @@ pub struct PendingEvent {
|
||||
pub detail: PendingEventDetail,
|
||||
}
|
||||
|
||||
/// Result returned from lifecycle send operations.
|
||||
pub enum SendEventResult {
|
||||
LifecycleSent,
|
||||
LifecycleDropped,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub enum SendMessageResult {
|
||||
MessageSent { message_id: String },
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub enum OrchestrationEventServiceEvent {
|
||||
/// Signals that a conversation may have pending orchestration events
|
||||
/// ready to drain.
|
||||
EventsReady { conversation_id: AIConversationId },
|
||||
}
|
||||
|
||||
/// Synchronous state manager for orchestration event queuing, delivery
|
||||
/// tracking, lifecycle dispatch, and readiness detection.
|
||||
/// Synchronous state manager for orchestration event queuing, delivery tracking, and readiness detection.
|
||||
pub struct OrchestrationEventService {
|
||||
pending_events: HashMap<AIConversationId, Vec<PendingEvent>>,
|
||||
awaiting_server_echo_events: HashMap<AIConversationId, Vec<PendingEvent>>,
|
||||
lifecycle_subscription_routes: HashMap<AIConversationId, Vec<LifecycleSubscriptionRoute>>,
|
||||
conversation_statuses: HashMap<AIConversationId, ConversationStatus>,
|
||||
}
|
||||
|
||||
impl OrchestrationEventService {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, move |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
|
||||
me.handle_history_event(event, ctx);
|
||||
});
|
||||
Self::new_without_subscriptions()
|
||||
@@ -128,223 +102,10 @@ impl OrchestrationEventService {
|
||||
Self {
|
||||
pending_events: HashMap::new(),
|
||||
awaiting_server_echo_events: HashMap::new(),
|
||||
lifecycle_subscription_routes: HashMap::new(),
|
||||
conversation_statuses: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_lifecycle_subscription(
|
||||
&mut self,
|
||||
source_conversation_id: AIConversationId,
|
||||
target_agent_id: String,
|
||||
subscribed_event_types: Option<Vec<LifecycleEventType>>,
|
||||
) {
|
||||
let routes = self
|
||||
.lifecycle_subscription_routes
|
||||
.entry(source_conversation_id)
|
||||
.or_default();
|
||||
if let Some(existing_route) = routes
|
||||
.iter_mut()
|
||||
.find(|route| route.target_agent_id == target_agent_id)
|
||||
{
|
||||
existing_route.subscribed_event_types = subscribed_event_types;
|
||||
return;
|
||||
}
|
||||
routes.push(LifecycleSubscriptionRoute {
|
||||
target_agent_id,
|
||||
subscribed_event_types,
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
pub fn emit_child_startup_started(
|
||||
&mut self,
|
||||
child_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
child_conversation_id,
|
||||
LifecycleEventType::Started,
|
||||
LifecycleEventDetailPayload::default(),
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
child_conversation_id,
|
||||
LifecycleEventType::Started,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn emit_child_startup_errored(
|
||||
&mut self,
|
||||
child_conversation_id: AIConversationId,
|
||||
reason: String,
|
||||
error_message: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
child_conversation_id,
|
||||
LifecycleEventType::Errored,
|
||||
LifecycleEventDetailPayload {
|
||||
stage: Some(LifecycleEventDetailStage::Startup),
|
||||
reason: Some(reason),
|
||||
error_message: Some(error_message),
|
||||
blocked_action: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
child_conversation_id,
|
||||
LifecycleEventType::Errored,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
fn dispatch_lifecycle_event(
|
||||
&mut self,
|
||||
source_conversation_id: AIConversationId,
|
||||
event_type: LifecycleEventType,
|
||||
detail_payload: LifecycleEventDetailPayload,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> SendEventResult {
|
||||
if event_type == LifecycleEventType::Unspecified {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::LifecycleEvent,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::InvalidLifecycleEventType,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: None,
|
||||
lifecycle_event_type: Some(
|
||||
lifecycle_event_type_name(event_type).to_string(),
|
||||
),
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
return SendEventResult::Error(
|
||||
"Cannot send lifecycle event with unspecified type".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let sender_agent_id = {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(source_conversation) = history_model.conversation(&source_conversation_id)
|
||||
else {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::LifecycleEvent,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::MissingSourceConversation,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: None,
|
||||
lifecycle_event_type: Some(
|
||||
lifecycle_event_type_name(event_type).to_string(),
|
||||
),
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
return SendEventResult::Error("Source conversation not found".to_string());
|
||||
};
|
||||
let Some(sender_agent_id) = source_conversation
|
||||
.server_conversation_token()
|
||||
.map(|token| token.as_str().to_string())
|
||||
else {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::LifecycleEvent,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::MissingSourceIdentifier,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: None,
|
||||
lifecycle_event_type: Some(
|
||||
lifecycle_event_type_name(event_type).to_string(),
|
||||
),
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
return SendEventResult::Error(
|
||||
"Source conversation has no server token — cannot send events".to_string(),
|
||||
);
|
||||
};
|
||||
sender_agent_id
|
||||
};
|
||||
|
||||
let Some(routes) = self
|
||||
.lifecycle_subscription_routes
|
||||
.get(&source_conversation_id)
|
||||
else {
|
||||
return SendEventResult::LifecycleDropped;
|
||||
};
|
||||
|
||||
let mut resolved_targets = Vec::new();
|
||||
{
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
for route in routes {
|
||||
if !is_subscribed(route.subscribed_event_types.as_deref(), event_type) {
|
||||
continue;
|
||||
}
|
||||
let Some(conversation_id) =
|
||||
history_model.conversation_id_for_agent_id(&route.target_agent_id)
|
||||
else {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::LifecycleEvent,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason: TeamAgentCommunicationFailureReason::UnknownAgent,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: Some(1),
|
||||
lifecycle_event_type: Some(
|
||||
lifecycle_event_type_name(event_type).to_string(),
|
||||
),
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
log::warn!(
|
||||
"OrchestrationEventService: could not resolve lifecycle target {}",
|
||||
route.target_agent_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
resolved_targets.push((route.target_agent_id.clone(), conversation_id));
|
||||
}
|
||||
}
|
||||
|
||||
if resolved_targets.is_empty() {
|
||||
return SendEventResult::LifecycleDropped;
|
||||
}
|
||||
|
||||
self.send_lifecycle_event(
|
||||
&sender_agent_id,
|
||||
&resolved_targets,
|
||||
event_type,
|
||||
&detail_payload,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn handle_history_event(
|
||||
&mut self,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
@@ -353,9 +114,12 @@ impl OrchestrationEventService {
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
conversation_id,
|
||||
is_restored,
|
||||
update,
|
||||
..
|
||||
} => self.on_conversation_status_updated(*conversation_id, *is_restored, ctx),
|
||||
} => {
|
||||
let is_restored = matches!(update, ConversationStatusUpdate::Restored);
|
||||
self.on_conversation_status_updated(*conversation_id, is_restored, ctx)
|
||||
}
|
||||
BlocklistAIHistoryEvent::UpdatedStreamingExchange {
|
||||
conversation_id,
|
||||
exchange_id,
|
||||
@@ -370,42 +134,6 @@ impl OrchestrationEventService {
|
||||
} => {
|
||||
for conversation_id in conversation_ids {
|
||||
self.sync_conversation_status(*conversation_id, ctx);
|
||||
// Under V1 local lifecycle dispatch, child status
|
||||
// transitions are forwarded to the parent via
|
||||
// `lifecycle_subscription_routes`. That map is not
|
||||
// persisted, so re-register subscriptions for each
|
||||
// restored child whose parent is loaded locally so that
|
||||
// child status transitions continue to propagate after
|
||||
// a restart. V2 uses the server event log and does not
|
||||
// need this.
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
let parent_agent_id = {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(child_conv) = history_model.conversation(conversation_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !child_conv.is_child_agent_conversation() {
|
||||
continue;
|
||||
}
|
||||
child_conv
|
||||
.parent_conversation_id()
|
||||
.and_then(|pid| history_model.conversation(&pid))
|
||||
.and_then(|p| p.server_conversation_token())
|
||||
.map(|t| t.as_str().to_string())
|
||||
};
|
||||
if let Some(parent_agent_id) = parent_agent_id {
|
||||
// `None` event-type filter = subscribe to all
|
||||
// lifecycle types. The original filter (if any)
|
||||
// is not persisted; subscribing broader than the
|
||||
// original is acceptable per the tech spec.
|
||||
self.register_lifecycle_subscription(
|
||||
*conversation_id,
|
||||
parent_agent_id,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BlocklistAIHistoryEvent::RemoveConversation {
|
||||
@@ -416,39 +144,12 @@ impl OrchestrationEventService {
|
||||
} => {
|
||||
self.pending_events.remove(conversation_id);
|
||||
self.awaiting_server_echo_events.remove(conversation_id);
|
||||
self.lifecycle_subscription_routes.remove(conversation_id);
|
||||
self.conversation_statuses.remove(conversation_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_lifecycle_dispatch_result(
|
||||
&self,
|
||||
child_conversation_id: AIConversationId,
|
||||
event_type: LifecycleEventType,
|
||||
result: SendEventResult,
|
||||
) {
|
||||
let event_type_name = lifecycle_event_type_name(event_type);
|
||||
match result {
|
||||
SendEventResult::LifecycleSent => {
|
||||
log::debug!(
|
||||
"LIFECYCLE-EVENT-DEBUG: Emitted child lifecycle event: event_type={event_type_name} child_conversation_id={child_conversation_id:?}"
|
||||
);
|
||||
}
|
||||
SendEventResult::LifecycleDropped => {
|
||||
log::debug!(
|
||||
"LIFECYCLE-EVENT-DEBUG: Dropped child lifecycle event due to lifecycle subscription filtering: event_type={event_type_name} child_conversation_id={child_conversation_id:?}"
|
||||
);
|
||||
}
|
||||
SendEventResult::Error(error) => {
|
||||
log::warn!(
|
||||
"LIFECYCLE-EVENT-WARN: Failed to emit lifecycle event for child agent: event_type={event_type_name} child_conversation_id={child_conversation_id:?} error={error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_conversation_status(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
@@ -470,440 +171,36 @@ impl OrchestrationEventService {
|
||||
is_restored: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let (is_child_agent_conversation, current_status, status_error_message) = {
|
||||
let current_status = {
|
||||
let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
|
||||
else {
|
||||
self.conversation_statuses.remove(&conversation_id);
|
||||
return;
|
||||
};
|
||||
(
|
||||
conversation.is_child_agent_conversation(),
|
||||
conversation.status().clone(),
|
||||
conversation.status_error_message().map(str::to_string),
|
||||
)
|
||||
conversation.status().clone()
|
||||
};
|
||||
|
||||
let previous_status = self
|
||||
.conversation_statuses
|
||||
self.conversation_statuses
|
||||
.insert(conversation_id, current_status.clone());
|
||||
let has_pending = self
|
||||
.pending_events
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|events| !events.is_empty());
|
||||
if !is_restored && matches!(¤t_status, ConversationStatus::Success) && has_pending {
|
||||
// Re-fire EventsReady whenever the conversation reaches a status
|
||||
// that `conversation_ready_for_pending_events` would accept, so
|
||||
// events queued while the stream was in flight drain as soon as
|
||||
// the stream finishes — either to `Success` or to
|
||||
// `WaitingForEvents` via a `wait_for_events` yield.
|
||||
if !is_restored
|
||||
&& matches!(
|
||||
¤t_status,
|
||||
ConversationStatus::Success | ConversationStatus::WaitingForEvents
|
||||
)
|
||||
&& has_pending
|
||||
{
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady { conversation_id });
|
||||
}
|
||||
|
||||
if is_restored || !is_child_agent_conversation {
|
||||
return;
|
||||
}
|
||||
|
||||
// When v2 is enabled, lifecycle events are delivered via the server
|
||||
// event log (poller reports → polls back → enqueues). Skip the v1
|
||||
// local dispatch to avoid duplicate delivery.
|
||||
if FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
match (previous_status.as_ref(), ¤t_status) {
|
||||
(Some(ConversationStatus::Success), ConversationStatus::InProgress) => {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
conversation_id,
|
||||
LifecycleEventType::Restarted,
|
||||
LifecycleEventDetailPayload::default(),
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
conversation_id,
|
||||
LifecycleEventType::Restarted,
|
||||
result,
|
||||
);
|
||||
}
|
||||
(Some(ConversationStatus::Blocked { .. }), ConversationStatus::InProgress) => {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
conversation_id,
|
||||
LifecycleEventType::Restarted,
|
||||
LifecycleEventDetailPayload::default(),
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
conversation_id,
|
||||
LifecycleEventType::Restarted,
|
||||
result,
|
||||
);
|
||||
}
|
||||
(Some(ConversationStatus::InProgress), ConversationStatus::Success) => {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
conversation_id,
|
||||
LifecycleEventType::Idle,
|
||||
LifecycleEventDetailPayload::default(),
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
conversation_id,
|
||||
LifecycleEventType::Idle,
|
||||
result,
|
||||
);
|
||||
|
||||
// Emit completion summary to parent and merge costs
|
||||
let (parent_id, summary) = {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let parent_id = history_model
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.parent_conversation_id());
|
||||
let summary = parent_id.and_then(|_| {
|
||||
let conv = history_model.conversation(&conversation_id)?;
|
||||
let messages = conv.all_linearized_messages();
|
||||
messages.iter().rev().find_map(|msg| {
|
||||
let message_content = msg.message.as_ref()?;
|
||||
match message_content {
|
||||
warp_multi_agent_api::message::Message::AgentOutput(output)
|
||||
if !output.text.is_empty() =>
|
||||
{
|
||||
let text = if output.text.len() > 500 {
|
||||
format!("{}...", &output.text[..497])
|
||||
} else {
|
||||
output.text.clone()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
});
|
||||
(parent_id, summary)
|
||||
};
|
||||
if let Some(parent_id) = parent_id {
|
||||
// Merge child's token usage and costs into parent
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| {
|
||||
let child_usage: Option<(
|
||||
HashMap<String, api::response_event::stream_finished::TokenUsage>,
|
||||
crate::ai::agent::RequestCost,
|
||||
)> = history_model.conversation(&conversation_id).map(|c| {
|
||||
(
|
||||
c.total_token_usage_by_model().clone(),
|
||||
c.total_request_cost(),
|
||||
)
|
||||
});
|
||||
if let Some((token_usage, request_cost)) = child_usage {
|
||||
if let Some(parent) = history_model.conversation_mut(&parent_id) {
|
||||
parent.merge_child_usage_raw(&token_usage, request_cost);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if summary.is_some() {
|
||||
self.route_subagent_completion_summary(conversation_id, parent_id, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(ConversationStatus::InProgress), ConversationStatus::Error) => {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
conversation_id,
|
||||
LifecycleEventType::Errored,
|
||||
LifecycleEventDetailPayload {
|
||||
stage: Some(LifecycleEventDetailStage::Runtime),
|
||||
reason: Some("conversation_error".to_string()),
|
||||
error_message: status_error_message,
|
||||
blocked_action: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
conversation_id,
|
||||
LifecycleEventType::Errored,
|
||||
result,
|
||||
);
|
||||
}
|
||||
(
|
||||
Some(ConversationStatus::InProgress),
|
||||
ConversationStatus::Blocked { blocked_action },
|
||||
) => {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
conversation_id,
|
||||
LifecycleEventType::Blocked,
|
||||
LifecycleEventDetailPayload {
|
||||
stage: None,
|
||||
reason: None,
|
||||
error_message: None,
|
||||
blocked_action: Some(blocked_action.clone()),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
conversation_id,
|
||||
LifecycleEventType::Blocked,
|
||||
result,
|
||||
);
|
||||
}
|
||||
(Some(ConversationStatus::InProgress), ConversationStatus::Cancelled)
|
||||
| (Some(ConversationStatus::Blocked { .. }), ConversationStatus::Cancelled) => {
|
||||
let result = self.dispatch_lifecycle_event(
|
||||
conversation_id,
|
||||
LifecycleEventType::Cancelled,
|
||||
LifecycleEventDetailPayload::default(),
|
||||
ctx,
|
||||
);
|
||||
self.log_lifecycle_dispatch_result(
|
||||
conversation_id,
|
||||
LifecycleEventType::Cancelled,
|
||||
result,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an orchestration event from `source_conversation_id` to each agent
|
||||
/// in `target_agent_ids`. Resolves addresses, queues, and emits
|
||||
/// `EventsReady` for each target conversation.
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
source_conversation_id: AIConversationId,
|
||||
target_agent_ids: &[String],
|
||||
subject: String,
|
||||
message_body: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> SendMessageResult {
|
||||
let (sender_agent_id, resolved_targets) = {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(source_conversation) = history_model.conversation(&source_conversation_id)
|
||||
else {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::MissingSourceConversation,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: Some(target_agent_ids.len()),
|
||||
lifecycle_event_type: None,
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
let error = "Source conversation not found".to_string();
|
||||
self.log_send_message_error(
|
||||
source_conversation_id,
|
||||
target_agent_ids,
|
||||
&subject,
|
||||
&error,
|
||||
);
|
||||
return SendMessageResult::Error(error);
|
||||
};
|
||||
let Some(sender_agent_id) = source_conversation
|
||||
.server_conversation_token()
|
||||
.map(|token| token.as_str().to_string())
|
||||
else {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::MissingSourceIdentifier,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: Some(target_agent_ids.len()),
|
||||
lifecycle_event_type: None,
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
let error =
|
||||
"Source conversation has no server token — cannot send events".to_string();
|
||||
self.log_send_message_error(
|
||||
source_conversation_id,
|
||||
target_agent_ids,
|
||||
&subject,
|
||||
&error,
|
||||
);
|
||||
return SendMessageResult::Error(error);
|
||||
};
|
||||
|
||||
let mut resolved_targets = Vec::new();
|
||||
for agent_id in target_agent_ids {
|
||||
match history_model.conversation_id_for_agent_id(agent_id) {
|
||||
Some(conversation_id) => {
|
||||
resolved_targets.push((agent_id.clone(), conversation_id));
|
||||
}
|
||||
None => {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::UnknownAgent,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: Some(target_agent_ids.len()),
|
||||
lifecycle_event_type: None,
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
let error = format!("Unknown agent address: {agent_id}");
|
||||
self.log_send_message_error(
|
||||
source_conversation_id,
|
||||
target_agent_ids,
|
||||
&subject,
|
||||
&error,
|
||||
);
|
||||
return SendMessageResult::Error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
(sender_agent_id, resolved_targets)
|
||||
};
|
||||
|
||||
if resolved_targets.is_empty() {
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::Local,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V1,
|
||||
failure_reason: TeamAgentCommunicationFailureReason::NoTargets,
|
||||
source_conversation_id,
|
||||
source_run_id: None,
|
||||
target_count: Some(0),
|
||||
lifecycle_event_type: None,
|
||||
error_message: None,
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
let error = "No target agents provided".to_string();
|
||||
self.log_send_message_error(source_conversation_id, target_agent_ids, &subject, &error);
|
||||
return SendMessageResult::Error(error);
|
||||
}
|
||||
|
||||
self.send_message_event(
|
||||
&sender_agent_id,
|
||||
&resolved_targets,
|
||||
target_agent_ids,
|
||||
subject,
|
||||
message_body,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
fn send_message_event(
|
||||
&mut self,
|
||||
sender_agent_id: &str,
|
||||
resolved_targets: &[(String, AIConversationId)],
|
||||
target_agent_ids: &[String],
|
||||
subject: String,
|
||||
message_body: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> SendMessageResult {
|
||||
// One logical message fanout maps to many delivery envelopes (message rows).
|
||||
// We keep `message_id` stable across targets so dedupe/threading can reason
|
||||
// about a single message delivered to multiple recipients.
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
|
||||
for (_, target_conversation_id) in resolved_targets {
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
|
||||
let pending = PendingEvent {
|
||||
event_id,
|
||||
source_agent_id: sender_agent_id.to_string(),
|
||||
attempt_count: 0,
|
||||
detail: PendingEventDetail::Message {
|
||||
message_id: message_id.clone(),
|
||||
addresses: target_agent_ids.to_vec(),
|
||||
subject: subject.clone(),
|
||||
message_body: message_body.clone(),
|
||||
},
|
||||
};
|
||||
self.pending_events
|
||||
.entry(*target_conversation_id)
|
||||
.or_default()
|
||||
.push(pending);
|
||||
|
||||
// Signal the controller to check this conversation for pending events.
|
||||
// The controller will check readiness (ownership, no in-flight) before draining.
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
|
||||
conversation_id: *target_conversation_id,
|
||||
});
|
||||
}
|
||||
|
||||
SendMessageResult::MessageSent { message_id }
|
||||
}
|
||||
|
||||
fn log_send_message_error(
|
||||
&self,
|
||||
source_conversation_id: AIConversationId,
|
||||
target_agent_ids: &[String],
|
||||
subject: &str,
|
||||
error: &str,
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to send child-agent message: source_conversation_id={source_conversation_id:?} target_agent_ids={target_agent_ids:?} subject={subject:?} error={error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Broadcast a lifecycle signal to subscribed targets.
|
||||
/// Enqueues an in-memory `AgentEvent` for controller delivery.
|
||||
fn send_lifecycle_event(
|
||||
&mut self,
|
||||
sender_agent_id: &str,
|
||||
resolved_targets: &[(String, AIConversationId)],
|
||||
event_type: LifecycleEventType,
|
||||
detail_payload: &LifecycleEventDetailPayload,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> SendEventResult {
|
||||
if event_type == LifecycleEventType::Unspecified {
|
||||
return SendEventResult::Error(
|
||||
"Cannot send lifecycle event with unspecified type".to_string(),
|
||||
);
|
||||
}
|
||||
// Use one timestamp for every target in this fanout so all delivered copies of
|
||||
// the same logical lifecycle signal carry identical `occurred_at` semantics.
|
||||
let occurred_at = chrono::Utc::now();
|
||||
let occurred_at_proto = prost_types::Timestamp {
|
||||
seconds: occurred_at.timestamp(),
|
||||
nanos: occurred_at.timestamp_subsec_nanos() as i32,
|
||||
};
|
||||
for (_, target_conversation_id) in resolved_targets {
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let agent_event = build_lifecycle_event(
|
||||
event_id.clone(),
|
||||
sender_agent_id.to_string(),
|
||||
event_type,
|
||||
occurred_at_proto,
|
||||
detail_payload,
|
||||
);
|
||||
|
||||
let pending = PendingEvent {
|
||||
event_id: event_id.clone(),
|
||||
source_agent_id: sender_agent_id.to_string(),
|
||||
attempt_count: 0,
|
||||
detail: PendingEventDetail::Lifecycle { event: agent_event },
|
||||
};
|
||||
self.enqueue_lifecycle_event(*target_conversation_id, pending);
|
||||
|
||||
// Signal the controller to check this conversation for pending events.
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
|
||||
conversation_id: *target_conversation_id,
|
||||
});
|
||||
}
|
||||
if resolved_targets.is_empty() {
|
||||
SendEventResult::LifecycleDropped
|
||||
} else {
|
||||
SendEventResult::LifecycleSent
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_lifecycle_event(
|
||||
@@ -931,7 +228,7 @@ impl OrchestrationEventService {
|
||||
}
|
||||
|
||||
/// Accepts pre-built events from the v2 streamer and enqueues them
|
||||
/// for drain by the controller via the normal v1 path.
|
||||
/// for drain by the controller via the normal injection path.
|
||||
/// Lifecycle events go through coalescing and cap enforcement.
|
||||
pub fn enqueue_event_batch(
|
||||
&mut self,
|
||||
@@ -955,6 +252,13 @@ impl OrchestrationEventService {
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady { conversation_id });
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(target_family = "wasm")))]
|
||||
pub fn has_pending_events(&self, conversation_id: AIConversationId) -> bool {
|
||||
self.pending_events
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|events| !events.is_empty())
|
||||
}
|
||||
|
||||
/// Drain and return all pending events for a conversation.
|
||||
fn drain_pending_events(&mut self, conversation_id: &AIConversationId) -> Vec<PendingEvent> {
|
||||
self.pending_events
|
||||
@@ -1311,18 +615,6 @@ impl OrchestrationEventService {
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` means \"subscribe to all lifecycle types\" (input omitted).
|
||||
/// `Some([])` means subscribe to no lifecycle events.
|
||||
fn is_subscribed(
|
||||
subscription: Option<&[LifecycleEventType]>,
|
||||
event_type: LifecycleEventType,
|
||||
) -> bool {
|
||||
match subscription {
|
||||
None => true,
|
||||
Some(subscription) => subscription.contains(&event_type),
|
||||
}
|
||||
}
|
||||
|
||||
fn did_event_round_trip_through_server(
|
||||
pending_event: &PendingEvent,
|
||||
echoed_message_ids: &HashSet<&str>,
|
||||
@@ -1342,22 +634,6 @@ fn did_event_round_trip_through_server(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
pub(super) fn lifecycle_event_type_name(event_type: LifecycleEventType) -> &'static str {
|
||||
match event_type {
|
||||
LifecycleEventType::Started => "started",
|
||||
LifecycleEventType::Idle => "idle",
|
||||
LifecycleEventType::Restarted => "restarted",
|
||||
LifecycleEventType::InProgress => "in_progress",
|
||||
LifecycleEventType::Succeeded => "succeeded",
|
||||
LifecycleEventType::Failed => "failed",
|
||||
LifecycleEventType::Errored => "errored",
|
||||
LifecycleEventType::Cancelled => "cancelled",
|
||||
LifecycleEventType::Blocked => "blocked",
|
||||
LifecycleEventType::Unspecified => "unspecified",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_lifecycle_event(
|
||||
event_id: String,
|
||||
sender_agent_id: String,
|
||||
|
||||
Reference in New Issue
Block a user