Make direct-provider agent runs durable
This commit is contained in:
@@ -35,7 +35,8 @@ pub use execute::{
|
||||
};
|
||||
use futures::future::{join_all, BoxFuture};
|
||||
use galaxy_agent_core::{
|
||||
PermissionDecision, PermissionKind, PermissionRequest, ToolEvent, ToolResult, ToolResultStatus,
|
||||
ExternalWorkId, PendingToolBatch, PermissionDecision, PermissionKind, PermissionRequest,
|
||||
ToolEvent, ToolResult, ToolResultStatus,
|
||||
};
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
@@ -71,6 +72,7 @@ use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
|
||||
use crate::ai::runtime::ProviderToolExecutionRef;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model_events::ModelEventDispatcher;
|
||||
use crate::terminal::TerminalModel;
|
||||
@@ -172,6 +174,22 @@ struct RunningActions {
|
||||
action_ids: Vec<AIAgentActionId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub(super) enum ProviderActionQueueError {
|
||||
#[error("provider action set mismatch: expected {expected:?}, received {received:?}")]
|
||||
ActionSetMismatch {
|
||||
expected: Vec<String>,
|
||||
received: Vec<String>,
|
||||
},
|
||||
#[error("provider action '{call_id}' is already correlated to active work")]
|
||||
ExistingCorrelation { call_id: String },
|
||||
}
|
||||
|
||||
type ProviderActionCorrelation = (
|
||||
(AIConversationId, AIAgentActionId),
|
||||
ProviderToolExecutionRef,
|
||||
);
|
||||
|
||||
impl RunningActions {
|
||||
fn new(phase: RunningActionPhase, action_id: AIAgentActionId) -> Self {
|
||||
Self {
|
||||
@@ -268,6 +286,13 @@ fn permission_kind_for_action(action: &AIAgentActionType) -> PermissionKind {
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_action_results_by_order(
|
||||
results: &mut [Arc<AIAgentActionResult>],
|
||||
action_order: &HashMap<AIAgentActionId, usize>,
|
||||
) {
|
||||
results.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
|
||||
fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bool) -> ToolResult {
|
||||
let status = if permission_denied {
|
||||
ToolResultStatus::Denied
|
||||
@@ -626,6 +651,10 @@ pub struct BlocklistAIActionModel {
|
||||
/// than reconstructing them from the legacy request protobuf.
|
||||
finished_tool_results: HashMap<AIConversationId, Vec<ToolResult>>,
|
||||
|
||||
/// Provider-owned action results retained until their exact tool batch is fully committed.
|
||||
provider_finished_action_results:
|
||||
HashMap<(AIConversationId, ExternalWorkId), Vec<Arc<AIAgentActionResult>>>,
|
||||
|
||||
/// Original order for the current batch of actions.
|
||||
///
|
||||
/// We maintain this so that even though we might process actions in parallel,
|
||||
@@ -635,6 +664,10 @@ pub struct BlocklistAIActionModel {
|
||||
/// Permission-card rejections that still need a correlated completion event.
|
||||
denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
|
||||
|
||||
/// Durable provider work identity for actions owned by an active provider run.
|
||||
provider_tool_executions:
|
||||
HashMap<(AIConversationId, AIAgentActionId), ProviderToolExecutionRef>,
|
||||
|
||||
/// Past actions and their corresponding statuses from previous AI exchanges.
|
||||
past_action_results: HashMap<AIAgentActionId, Arc<AIAgentActionResult>>,
|
||||
|
||||
@@ -669,10 +702,18 @@ impl BlocklistAIActionModel {
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_model(&executor, move |me, _, event, ctx| match event {
|
||||
BlocklistAIActionExecutorEvent::ExecutingAction { action_id } => {
|
||||
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone()));
|
||||
BlocklistAIActionExecutorEvent::ExecutingAction {
|
||||
action_id,
|
||||
conversation_id,
|
||||
} => {
|
||||
let execution_ref = me.provider_tool_execution_ref(*conversation_id, action_id);
|
||||
ctx.emit(BlocklistAIActionEvent::ExecutingAction {
|
||||
action_id: action_id.clone(),
|
||||
execution_ref: execution_ref.clone(),
|
||||
});
|
||||
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||
action_id: action_id.clone(),
|
||||
execution_ref,
|
||||
event: ToolEvent::Started {
|
||||
call_id: action_id.to_string(),
|
||||
},
|
||||
@@ -710,11 +751,13 @@ impl BlocklistAIActionModel {
|
||||
pending_actions: Default::default(),
|
||||
finished_action_results: Default::default(),
|
||||
finished_tool_results: Default::default(),
|
||||
provider_finished_action_results: Default::default(),
|
||||
executor,
|
||||
past_action_results: HashMap::new(),
|
||||
running_actions: Default::default(),
|
||||
action_order: Default::default(),
|
||||
denied_permissions: Default::default(),
|
||||
provider_tool_executions: Default::default(),
|
||||
terminal_view_id,
|
||||
pending_preprocessed_actions: Default::default(),
|
||||
is_view_only: false,
|
||||
@@ -752,7 +795,10 @@ impl BlocklistAIActionModel {
|
||||
action_id.clone(),
|
||||
RunningActionPhase::Serial,
|
||||
);
|
||||
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone()));
|
||||
ctx.emit(BlocklistAIActionEvent::ExecutingAction {
|
||||
action_id: action_id.clone(),
|
||||
execution_ref: self.provider_tool_execution_ref(conversation_id, action_id),
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns true if the action model is operating in view-only mode (used for shared-session viewers).
|
||||
@@ -942,9 +988,14 @@ impl BlocklistAIActionModel {
|
||||
fn sort_finished_results(&mut self, conversation_id: AIConversationId) {
|
||||
if let Some(action_order) = self.action_order.get(&conversation_id) {
|
||||
if let Some(finished_results) = self.finished_action_results.get_mut(&conversation_id) {
|
||||
finished_results.sort_by_key(|result| {
|
||||
action_order.get(&result.id).copied().unwrap_or(usize::MAX)
|
||||
});
|
||||
sort_action_results_by_order(finished_results, action_order);
|
||||
}
|
||||
for ((finished_conversation_id, _), finished_results) in
|
||||
&mut self.provider_finished_action_results
|
||||
{
|
||||
if *finished_conversation_id == conversation_id {
|
||||
sort_action_results_by_order(finished_results, action_order);
|
||||
}
|
||||
}
|
||||
if let Some(tool_results) = self.finished_tool_results.get_mut(&conversation_id) {
|
||||
let tool_order = action_order
|
||||
@@ -1113,6 +1164,7 @@ impl BlocklistAIActionModel {
|
||||
// Search through all conversations' finished action results
|
||||
self.finished_action_results
|
||||
.values()
|
||||
.chain(self.provider_finished_action_results.values())
|
||||
.flat_map(|results| results.iter())
|
||||
.find(|result| &result.id == id)
|
||||
.or_else(|| self.past_action_results.get(id))
|
||||
@@ -1288,11 +1340,14 @@ impl BlocklistAIActionModel {
|
||||
"reason": format!("{reason:?}"),
|
||||
}),
|
||||
);
|
||||
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(
|
||||
action.id.clone(),
|
||||
));
|
||||
let execution_ref = self.provider_tool_execution_ref(conversation_id, &action.id);
|
||||
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
|
||||
action_id: action.id.clone(),
|
||||
execution_ref: execution_ref.clone(),
|
||||
});
|
||||
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||
action_id: action.id.clone(),
|
||||
execution_ref,
|
||||
event: ToolEvent::PermissionRequested {
|
||||
request: PermissionRequest {
|
||||
id: permission_request_id(&action.id),
|
||||
@@ -1389,6 +1444,7 @@ impl BlocklistAIActionModel {
|
||||
);
|
||||
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||
action_id: action_id.clone(),
|
||||
execution_ref: self.provider_tool_execution_ref(conversation_id, &action_id),
|
||||
event: ToolEvent::PermissionResolved {
|
||||
request_id: permission_request_id(&action_id),
|
||||
call_id: action_id.to_string(),
|
||||
@@ -1461,6 +1517,40 @@ impl BlocklistAIActionModel {
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_tool_execution_ref(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
action_id: &AIAgentActionId,
|
||||
) -> Option<ProviderToolExecutionRef> {
|
||||
self.provider_tool_executions
|
||||
.get(&(conversation_id, action_id.clone()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn queue_provider_actions(
|
||||
&mut self,
|
||||
actions: Vec<AIAgentAction>,
|
||||
conversation_id: AIConversationId,
|
||||
batch: &PendingToolBatch,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), ProviderActionQueueError> {
|
||||
let refs = provider_action_correlations(&actions, conversation_id, batch)?;
|
||||
for ((_, action_id), _) in &refs {
|
||||
if self
|
||||
.provider_tool_executions
|
||||
.contains_key(&(conversation_id, action_id.clone()))
|
||||
{
|
||||
return Err(ProviderActionQueueError::ExistingCorrelation {
|
||||
call_id: action_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.provider_tool_executions.extend(refs);
|
||||
self.queue_actions(actions, conversation_id, ctx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queues the `actions` in the given iterator for the given conversation,
|
||||
/// to be dispatched in the order in which they appear in the iterator.
|
||||
pub(super) fn queue_actions(
|
||||
@@ -1553,11 +1643,18 @@ impl BlocklistAIActionModel {
|
||||
// as otherwise tools get stuck in a pending state on the viewer's side of things. This check
|
||||
// must be scoped to the current conversation as some providers generate tool call IDs that
|
||||
// only unique within a conversation.
|
||||
if self
|
||||
let has_finished_result = self
|
||||
.finished_action_results
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|results| results.iter().any(|r| r.id == action_id))
|
||||
{
|
||||
.is_some_and(|results| results.iter().any(|result| result.id == action_id))
|
||||
|| self
|
||||
.provider_finished_action_results
|
||||
.iter()
|
||||
.filter(|((finished_conversation_id, _), _)| {
|
||||
*finished_conversation_id == conversation_id
|
||||
})
|
||||
.any(|(_, results)| results.iter().any(|result| result.id == action_id));
|
||||
if has_finished_result {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1577,7 +1674,10 @@ impl BlocklistAIActionModel {
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.push_back(action);
|
||||
ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id));
|
||||
ctx.emit(BlocklistAIActionEvent::QueuedAction {
|
||||
execution_ref: self.provider_tool_execution_ref(conversation_id, &action_id),
|
||||
action_id,
|
||||
});
|
||||
}
|
||||
self.try_to_execute_available_actions(conversation_id, ctx);
|
||||
}
|
||||
@@ -1681,6 +1781,14 @@ impl BlocklistAIActionModel {
|
||||
executor.cancel_all_running_async_actions_for_conversation(conversation_id, reason, ctx)
|
||||
});
|
||||
|
||||
if let Some(preprocessing) = self.pending_preprocessed_actions.remove(&conversation_id) {
|
||||
self.provider_tool_executions
|
||||
.retain(|(correlated_conversation_id, action_id), _| {
|
||||
*correlated_conversation_id != conversation_id
|
||||
|| !preprocessing.contains(action_id)
|
||||
});
|
||||
}
|
||||
|
||||
let Some(actions_to_cancel) = self.pending_actions.get_mut(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
@@ -1752,10 +1860,14 @@ impl BlocklistAIActionModel {
|
||||
);
|
||||
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||
action_id: pending_action.id.clone(),
|
||||
execution_ref: self
|
||||
.provider_tool_execution_ref(conversation_id, &pending_action.id),
|
||||
event: ToolEvent::PermissionResolved {
|
||||
request_id: permission_request_id(&pending_action.id),
|
||||
call_id: pending_action.id.to_string(),
|
||||
decision: PermissionDecision::Denied { reason: None },
|
||||
decision: PermissionDecision::Denied {
|
||||
reason: Some("Permission denied by the user.".to_string()),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1817,11 +1929,40 @@ impl BlocklistAIActionModel {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn provider_finished_action_results(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
work_id: &ExternalWorkId,
|
||||
) -> Vec<Arc<AIAgentActionResult>> {
|
||||
self.provider_finished_action_results
|
||||
.get(&(conversation_id, work_id.clone()))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn archive_provider_finished_action_results(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
work_id: &ExternalWorkId,
|
||||
) {
|
||||
let results = self
|
||||
.provider_finished_action_results
|
||||
.remove(&(conversation_id, work_id.clone()))
|
||||
.unwrap_or_default();
|
||||
for result in results {
|
||||
self.past_action_results.insert(result.id.clone(), result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears finished action results for a conversation. Used when reverting.
|
||||
pub(super) fn clear_finished_action_results(&mut self, conversation_id: AIConversationId) {
|
||||
self.action_order.remove(&conversation_id);
|
||||
self.finished_action_results.remove(&conversation_id);
|
||||
self.finished_tool_results.remove(&conversation_id);
|
||||
self.provider_finished_action_results
|
||||
.retain(|(finished_conversation_id, _), _| {
|
||||
*finished_conversation_id != conversation_id
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1921,14 +2062,19 @@ impl BlocklistAIActionModel {
|
||||
}
|
||||
}
|
||||
|
||||
let execution_ref = self
|
||||
.provider_tool_executions
|
||||
.remove(&(conversation_id, action_result.id.clone()));
|
||||
let permission_denied = self
|
||||
.denied_permissions
|
||||
.remove(&(conversation_id, action_result.id.clone()));
|
||||
let tool_result = domain_tool_result(&action_result, permission_denied);
|
||||
self.finished_tool_results
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.push(tool_result.clone());
|
||||
if execution_ref.is_none() {
|
||||
self.finished_tool_results
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.push(tool_result.clone());
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
log_tool_event(
|
||||
ctx,
|
||||
@@ -1955,17 +2101,29 @@ impl BlocklistAIActionModel {
|
||||
"error": action_result_error_summary(&action_result.result),
|
||||
}),
|
||||
);
|
||||
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||
action_id: action_result.id.clone(),
|
||||
event: ToolEvent::Completed {
|
||||
result: tool_result,
|
||||
},
|
||||
});
|
||||
// Permission denial completes provider-owned calls when the permission decision is
|
||||
// applied, so emitting a second correlated completion would violate exactly-once delivery.
|
||||
if execution_ref.is_none() || !permission_denied {
|
||||
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||
action_id: action_result.id.clone(),
|
||||
execution_ref: execution_ref.clone(),
|
||||
event: ToolEvent::Completed {
|
||||
result: tool_result,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
self.finished_action_results
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.push(action_result);
|
||||
if let Some(execution_ref) = &execution_ref {
|
||||
self.provider_finished_action_results
|
||||
.entry((conversation_id, execution_ref.work_id()))
|
||||
.or_default()
|
||||
.push(action_result);
|
||||
} else {
|
||||
self.finished_action_results
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.push(action_result);
|
||||
}
|
||||
|
||||
if self
|
||||
.running_actions
|
||||
@@ -1986,6 +2144,7 @@ impl BlocklistAIActionModel {
|
||||
action_id,
|
||||
conversation_id,
|
||||
cancellation_reason,
|
||||
execution_ref: execution_ref.clone(),
|
||||
});
|
||||
|
||||
if self
|
||||
@@ -1999,8 +2158,10 @@ impl BlocklistAIActionModel {
|
||||
// completion (no cancellation reason) is resolved by the controller's
|
||||
// follow-up handling. Stamping here for any of those would clobber the real
|
||||
// status and message.
|
||||
if cancellation_reason
|
||||
.is_some_and(|r| matches!(r.conversation_outcome(), CancellationOutcome::Cancelled))
|
||||
if execution_ref.is_none()
|
||||
&& cancellation_reason.is_some_and(|r| {
|
||||
matches!(r.conversation_outcome(), CancellationOutcome::Cancelled)
|
||||
})
|
||||
{
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
|
||||
// Treat action result as authoritative for determining status.
|
||||
@@ -2110,23 +2271,63 @@ impl BlocklistAIActionModel {
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_action_correlations(
|
||||
actions: &[AIAgentAction],
|
||||
conversation_id: AIConversationId,
|
||||
batch: &PendingToolBatch,
|
||||
) -> Result<Vec<ProviderActionCorrelation>, ProviderActionQueueError> {
|
||||
let expected = batch.unresolved_call_ids();
|
||||
let received = actions
|
||||
.iter()
|
||||
.map(|action| action.id.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if expected != received {
|
||||
return Err(ProviderActionQueueError::ActionSetMismatch { expected, received });
|
||||
}
|
||||
|
||||
Ok(actions
|
||||
.iter()
|
||||
.map(|action| {
|
||||
(
|
||||
(conversation_id, action.id.clone()),
|
||||
ProviderToolExecutionRef::new(
|
||||
conversation_id,
|
||||
&batch.work_id,
|
||||
action.id.to_string(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BlocklistAIActionEvent {
|
||||
/// Emitted when the action with the given ID is enqueued for execution.
|
||||
QueuedAction(AIAgentActionId),
|
||||
QueuedAction {
|
||||
action_id: AIAgentActionId,
|
||||
execution_ref: Option<ProviderToolExecutionRef>,
|
||||
},
|
||||
/// Emitted when the action with the given ID requires user confirmation to execute.
|
||||
ActionBlockedOnUserConfirmation(AIAgentActionId),
|
||||
ActionBlockedOnUserConfirmation {
|
||||
action_id: AIAgentActionId,
|
||||
execution_ref: Option<ProviderToolExecutionRef>,
|
||||
},
|
||||
/// Emitted when the action with the given ID begins execution.
|
||||
ExecutingAction(AIAgentActionId),
|
||||
ExecutingAction {
|
||||
action_id: AIAgentActionId,
|
||||
execution_ref: Option<ProviderToolExecutionRef>,
|
||||
},
|
||||
/// Emitted when the action with the given ID has finished.
|
||||
FinishedAction {
|
||||
action_id: AIAgentActionId,
|
||||
conversation_id: AIConversationId,
|
||||
cancellation_reason: Option<CancellationReason>,
|
||||
execution_ref: Option<ProviderToolExecutionRef>,
|
||||
},
|
||||
/// Provider-neutral permission and execution lifecycle event for runtime consumers.
|
||||
ToolLifecycle {
|
||||
action_id: AIAgentActionId,
|
||||
execution_ref: Option<ProviderToolExecutionRef>,
|
||||
event: ToolEvent,
|
||||
},
|
||||
InitProject(AIAgentActionId),
|
||||
@@ -2142,10 +2343,10 @@ pub enum BlocklistAIActionEvent {
|
||||
impl BlocklistAIActionEvent {
|
||||
pub fn action_id(&self) -> &AIAgentActionId {
|
||||
match self {
|
||||
BlocklistAIActionEvent::QueuedAction(action_id) => action_id,
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) => action_id,
|
||||
BlocklistAIActionEvent::ExecutingAction(action_id) => action_id,
|
||||
BlocklistAIActionEvent::FinishedAction { action_id, .. } => action_id,
|
||||
BlocklistAIActionEvent::QueuedAction { action_id, .. }
|
||||
| BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
|
||||
| BlocklistAIActionEvent::ExecutingAction { action_id, .. }
|
||||
| BlocklistAIActionEvent::FinishedAction { action_id, .. } => action_id,
|
||||
BlocklistAIActionEvent::ToolLifecycle { action_id, .. } => action_id,
|
||||
BlocklistAIActionEvent::InitProject(action_id) => action_id,
|
||||
BlocklistAIActionEvent::ToggleCodeReview(action_id) => action_id,
|
||||
|
||||
@@ -688,6 +688,7 @@ impl BlocklistAIActionExecutor {
|
||||
|
||||
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
||||
action_id: action_id.clone(),
|
||||
conversation_id,
|
||||
});
|
||||
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
||||
result: Arc::new(AIAgentActionResult {
|
||||
@@ -904,6 +905,7 @@ impl BlocklistAIActionExecutor {
|
||||
);
|
||||
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
||||
action_id: action_id.clone(),
|
||||
conversation_id,
|
||||
});
|
||||
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
|
||||
ctx.spawn(execute_future, move |me, result, ctx| {
|
||||
@@ -932,6 +934,7 @@ impl BlocklistAIActionExecutor {
|
||||
AnyActionExecution::Sync(action_result) => {
|
||||
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
|
||||
action_id: action_id.clone(),
|
||||
conversation_id,
|
||||
});
|
||||
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
||||
result: Arc::new(AIAgentActionResult {
|
||||
@@ -1140,9 +1143,10 @@ impl Entity for BlocklistAIActionExecutor {
|
||||
}
|
||||
|
||||
pub enum BlocklistAIActionExecutorEvent {
|
||||
/// Emitted when an action is execution starts.
|
||||
/// Emitted when an action begins execution.
|
||||
ExecutingAction {
|
||||
action_id: AIAgentActionId,
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
|
||||
/// Emitted when an action has finished.
|
||||
|
||||
@@ -4,7 +4,8 @@ use std::sync::Arc;
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionResultType, AnyFileContent, FileContext, GrepResult, ReadFilesResult,
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
|
||||
GrepResult, ReadFilesResult,
|
||||
};
|
||||
|
||||
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
|
||||
@@ -23,6 +24,36 @@ fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResu
|
||||
}
|
||||
}
|
||||
|
||||
fn action(id: &str) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from(id.to_string()),
|
||||
action: AIAgentActionType::InitProject,
|
||||
task_id: TaskId::new("task".to_string()),
|
||||
requires_result: true,
|
||||
tool_name: Some("init_project".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
|
||||
PendingToolBatch {
|
||||
work_id: galaxy_agent_core::ExternalWorkId {
|
||||
run_id: galaxy_agent_core::ProviderRunId::new("run"),
|
||||
epoch: galaxy_agent_core::RunEpoch::new(7),
|
||||
},
|
||||
calls: call_ids
|
||||
.iter()
|
||||
.map(|call_id| galaxy_agent_core::PendingToolCall {
|
||||
call: galaxy_agent_core::ToolCall {
|
||||
id: (*call_id).to_string(),
|
||||
name: "init_project".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
},
|
||||
state: galaxy_agent_core::PendingToolCallState::Proposed,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
|
||||
let mut current_phase = None;
|
||||
let mut count = 0;
|
||||
@@ -45,6 +76,34 @@ fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> us
|
||||
count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
|
||||
let conversation_id = AIConversationId::new();
|
||||
let batch = pending_tool_batch(&["first", "second"]);
|
||||
let actions = vec![action("first"), action("second")];
|
||||
|
||||
let correlations = provider_action_correlations(&actions, conversation_id, &batch).unwrap();
|
||||
assert_eq!(correlations.len(), 2);
|
||||
assert_eq!(correlations[0].0, (conversation_id, actions[0].id.clone()));
|
||||
assert_eq!(correlations[0].1.run_id, batch.work_id.run_id);
|
||||
assert_eq!(correlations[0].1.epoch, batch.work_id.epoch);
|
||||
assert_eq!(correlations[0].1.call_id, "first");
|
||||
|
||||
let error = provider_action_correlations(
|
||||
&[action("second"), action("first")],
|
||||
conversation_id,
|
||||
&batch,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
ProviderActionQueueError::ActionSetMismatch {
|
||||
expected: vec!["first".to_string(), "second".to_string()],
|
||||
received: vec!["second".to_string(), "first".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
|
||||
let phase =
|
||||
@@ -94,8 +153,7 @@ fn finished_results_stay_in_original_action_order() {
|
||||
make_action_result("second"),
|
||||
];
|
||||
|
||||
finished_results
|
||||
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
|
||||
sort_action_results_by_order(&mut finished_results, &action_order);
|
||||
|
||||
assert_eq!(
|
||||
finished_results[0].id,
|
||||
|
||||
@@ -4723,7 +4723,7 @@ impl AIBlock {
|
||||
}
|
||||
|
||||
match event {
|
||||
BlocklistAIActionEvent::ExecutingAction(..) => {
|
||||
BlocklistAIActionEvent::ExecutingAction { .. } => {
|
||||
match &me.autonomy_setting_speedbump {
|
||||
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
|
||||
action_id: speedbump_action_id,
|
||||
@@ -4793,7 +4793,7 @@ impl AIBlock {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(..) => {
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
|
||||
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
|
||||
}
|
||||
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
|
||||
@@ -4950,7 +4950,7 @@ impl AIBlock {
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIActionEvent::QueuedAction(action_id) => {
|
||||
BlocklistAIActionEvent::QueuedAction { action_id, .. } => {
|
||||
// Update search codebase view status when action is queued
|
||||
if let Some(view) = me.search_codebase_view.get(action_id) {
|
||||
view.update(ctx, |view, ctx| {
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::ai::agent::{
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
|
||||
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
|
||||
use crate::ai::blocklist::controller::PendingProviderCommandCompletion;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
|
||||
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
|
||||
@@ -56,6 +57,7 @@ struct PendingCommandCompletion {
|
||||
initial_requested_command_action_id: Option<AIAgentActionId>,
|
||||
prompt: String,
|
||||
completed_command: RunningCommand,
|
||||
exit_code: i32,
|
||||
final_turn_started: bool,
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ impl CLISubagentController {
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
|
||||
let mut terminal_model = me.terminal_model.lock();
|
||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||
active_block.update_is_agent_blocked(true);
|
||||
@@ -181,7 +183,7 @@ impl CLISubagentController {
|
||||
agent_has_control: active_block.is_agent_in_control(),
|
||||
});
|
||||
}
|
||||
BlocklistAIActionEvent::ExecutingAction(..) => {
|
||||
BlocklistAIActionEvent::ExecutingAction { .. } => {
|
||||
let mut terminal_model = me.terminal_model.lock();
|
||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||
active_block.update_is_agent_blocked(false);
|
||||
@@ -303,6 +305,7 @@ impl CLISubagentController {
|
||||
requested_command_id: requested_command_action_id.clone(),
|
||||
is_alt_screen_active: false,
|
||||
},
|
||||
exit_code,
|
||||
final_turn_started: false,
|
||||
})
|
||||
}
|
||||
@@ -319,16 +322,40 @@ impl CLISubagentController {
|
||||
};
|
||||
drop(terminal_model);
|
||||
|
||||
let Some(has_last_snapshot) = me
|
||||
let provider_consumed_completion = completion.as_ref().is_some_and(|completion| {
|
||||
me.controller.update(ctx, |controller, ctx| {
|
||||
controller.accept_provider_command_completion(
|
||||
completion.conversation_id,
|
||||
PendingProviderCommandCompletion::new(
|
||||
completion.completed_command.block_id.clone(),
|
||||
completion.initial_requested_command_action_id.clone(),
|
||||
completion.completed_command.command.clone(),
|
||||
completion.completed_command.grid_contents.clone(),
|
||||
completion.exit_code,
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
});
|
||||
let has_last_snapshot = me
|
||||
.active_subagents_by_block
|
||||
.get(&block_id)
|
||||
.map(|state| state.last_snapshot_at.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
.is_some_and(|state| state.last_snapshot_at.is_some());
|
||||
if has_last_snapshot {
|
||||
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
||||
}
|
||||
if provider_consumed_completion {
|
||||
me.finish_subagent(
|
||||
&block_id,
|
||||
conversation_id,
|
||||
requested_command_action_id,
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !me.active_subagents_by_block.contains_key(&block_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A Stop takeover intentionally cancels the subagent. The command may still
|
||||
// finish later, but that completion must not start a new assessment turn. Also
|
||||
@@ -483,7 +510,11 @@ impl CLISubagentController {
|
||||
if self
|
||||
.controller
|
||||
.as_ref(ctx)
|
||||
.has_active_stream_for_conversation(conversation_id, ctx)
|
||||
.has_active_provider_run(conversation_id)
|
||||
|| self
|
||||
.controller
|
||||
.as_ref(ctx)
|
||||
.has_active_stream_for_conversation(conversation_id, ctx)
|
||||
|| self
|
||||
.action_model
|
||||
.as_ref(ctx)
|
||||
@@ -737,13 +768,7 @@ impl CLISubagentController {
|
||||
.collect()
|
||||
};
|
||||
self.controller.update(ctx, |controller, ctx| {
|
||||
controller.resume_conversation(
|
||||
conversation_id,
|
||||
/*can_attempt_resume_on_error*/ true,
|
||||
/*is_auto_resume_after_error*/ false,
|
||||
resume_context,
|
||||
ctx,
|
||||
);
|
||||
controller.resume_conversation(conversation_id, resume_context, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ impl BlocklistAIStatusBar {
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
|
||||
BlocklistAIActionEvent::ExecutingAction(..)
|
||||
BlocklistAIActionEvent::ExecutingAction { .. }
|
||||
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
|
||||
_ => (),
|
||||
});
|
||||
|
||||
+2553
-479
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,88 +1,4 @@
|
||||
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
|
||||
|
||||
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
|
||||
// can_attempt_resume_on_error, is_online.
|
||||
|
||||
#[test]
|
||||
fn pre_action_failures_retry() {
|
||||
assert_eq!(
|
||||
recovery_action(false, true, true, true, true),
|
||||
RecoveryAction::RetryNow
|
||||
);
|
||||
// Resume eligibility is irrelevant pre-actions.
|
||||
assert_eq!(
|
||||
recovery_action(false, true, true, false, true),
|
||||
RecoveryAction::RetryNow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_action_failures_wait_for_connectivity_when_offline() {
|
||||
assert_eq!(
|
||||
recovery_action(false, true, true, true, false),
|
||||
RecoveryAction::RetryWhenOnline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_action_budget_exhaustion_is_terminal() {
|
||||
// The request has already been retried MAX_RETRIES times; stop.
|
||||
assert_eq!(
|
||||
recovery_action(false, true, false, true, true),
|
||||
RecoveryAction::Fail
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_action(false, true, false, true, false),
|
||||
RecoveryAction::Fail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_recoverable_pre_action_failure_is_terminal() {
|
||||
assert_eq!(
|
||||
recovery_action(false, false, true, true, true),
|
||||
RecoveryAction::Fail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_action_recoverable_failures_resume() {
|
||||
assert_eq!(
|
||||
recovery_action(true, true, true, true, true),
|
||||
RecoveryAction::Resume
|
||||
);
|
||||
// Offline doesn't change the decision; the resume spawn waits for connectivity.
|
||||
assert_eq!(
|
||||
recovery_action(true, true, true, true, false),
|
||||
RecoveryAction::Resume
|
||||
);
|
||||
// The in-request retry budget is irrelevant once actions have executed.
|
||||
assert_eq!(
|
||||
recovery_action(true, true, false, true, true),
|
||||
RecoveryAction::Resume
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_action_failures_without_resume_eligibility_are_terminal() {
|
||||
// Resume requests themselves run with can_attempt_resume_on_error=false,
|
||||
// bounding recovery to a single resume.
|
||||
assert_eq!(
|
||||
recovery_action(true, true, true, false, true),
|
||||
RecoveryAction::Fail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_recoverable_post_action_failure_is_terminal() {
|
||||
// A non-recoverable error (e.g. a client error) ends the conversation even
|
||||
// after actions have executed.
|
||||
assert_eq!(
|
||||
recovery_action(true, false, true, true, true),
|
||||
RecoveryAction::Fail
|
||||
);
|
||||
}
|
||||
|
||||
use super::is_interactive_remote_command;
|
||||
#[test]
|
||||
fn raw_interactive_ssh_is_treated_as_remote_for_acp() {
|
||||
for command in [
|
||||
|
||||
@@ -194,7 +194,6 @@ impl SlashCommandRequest {
|
||||
entrypoint,
|
||||
is_auto_resume_after_error: false,
|
||||
}),
|
||||
/*can_attempt_resume_on_error*/ true,
|
||||
is_queued_prompt,
|
||||
ctx,
|
||||
) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -563,6 +563,44 @@ impl BlocklistAIHistoryModel {
|
||||
conversation.write_updated_conversation_state(ctx);
|
||||
}
|
||||
|
||||
pub(crate) fn persist_active_provider_run_json(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
snapshot: Option<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), UpdateHistoryError> {
|
||||
let conversation = self
|
||||
.conversations_by_id
|
||||
.get_mut(&conversation_id)
|
||||
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
|
||||
conversation.set_active_provider_run_json(snapshot);
|
||||
conversation.write_updated_conversation_state(ctx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn rebind_provider_projection(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
task_id: &TaskId,
|
||||
exchange_id: AIAgentExchangeId,
|
||||
response_stream_id: ResponseStreamId,
|
||||
terminal_surface_id: EntityId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), UpdateHistoryError> {
|
||||
let conversation = self
|
||||
.conversations_by_id
|
||||
.get_mut(&conversation_id)
|
||||
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
|
||||
conversation.rebind_provider_projection(
|
||||
task_id,
|
||||
exchange_id,
|
||||
response_stream_id,
|
||||
terminal_surface_id,
|
||||
ctx,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
|
||||
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
|
||||
return;
|
||||
@@ -1652,6 +1690,7 @@ impl BlocklistAIHistoryModel {
|
||||
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: source_conversation.agent_backend().for_fork(),
|
||||
active_provider_run_json: None,
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
|
||||
reverted_action_ids,
|
||||
@@ -1816,6 +1855,7 @@ impl BlocklistAIHistoryModel {
|
||||
// be recomputed based on the retained exchanges in a follow-up.
|
||||
let conversation_data = AgentConversationData {
|
||||
agent_backend: conversation.agent_backend().for_fork(),
|
||||
active_provider_run_json: None,
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids,
|
||||
@@ -2824,6 +2864,9 @@ fn merged_remote_child_placeholder_conversation_data(
|
||||
// Placeholder authoritative.
|
||||
agent_backend: placeholder.agent_backend().clone(),
|
||||
|
||||
// Active process-local provider runs cannot be merged from a cloud transcript.
|
||||
active_provider_run_json: None,
|
||||
|
||||
// Cloud authoritative.
|
||||
server_conversation_token: cloud_conversation
|
||||
.server_conversation_token()
|
||||
|
||||
@@ -418,21 +418,21 @@ impl RequestedCommandView {
|
||||
if !is_finished {
|
||||
ctx.subscribe_to_model(action_model, |me, _, event, ctx| {
|
||||
match event {
|
||||
BlocklistAIActionEvent::QueuedAction(action_id)
|
||||
BlocklistAIActionEvent::QueuedAction { action_id, .. }
|
||||
if *action_id == me.action_id =>
|
||||
{
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
|
||||
if *action_id == me.action_id =>
|
||||
{
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
|
||||
action_id, ..
|
||||
} if *action_id == me.action_id => {
|
||||
if me.action_type.is_requested_command() {
|
||||
me.ensure_editor(ctx);
|
||||
}
|
||||
me.set_is_header_expanded(true, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIActionEvent::ExecutingAction(action_id)
|
||||
BlocklistAIActionEvent::ExecutingAction { action_id, .. }
|
||||
if *action_id == me.action_id =>
|
||||
{
|
||||
// For shared-session viewers, sync the command text from the action when it starts executing.
|
||||
|
||||
@@ -376,7 +376,7 @@ impl RunAgentsCardView {
|
||||
{
|
||||
ctx.notify();
|
||||
}
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
|
||||
if action_id == &action_id_for_action_events =>
|
||||
{
|
||||
// Normal case: streaming is complete and the action is
|
||||
|
||||
@@ -11,7 +11,6 @@ use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::super::controller::{BlocklistAIController, BlocklistAIControllerEvent};
|
||||
use crate::ai::agent::api::generate_multi_agent_output;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIIdentifiers, FileContext, PassiveCodeDiffEntry, PassiveSuggestionTrigger,
|
||||
|
||||
Reference in New Issue
Block a user