Reduce indexing overhead and repair agent search and child recovery
This commit is contained in:
@@ -818,57 +818,7 @@ impl AIConversation {
|
||||
&mut self,
|
||||
messages: Vec<crate::ai::provider::convert::ConversationMessage>,
|
||||
) {
|
||||
use crate::ai::provider::convert::{ContentPart, MessageContent};
|
||||
|
||||
// Cap the archive to prevent unbounded growth. The archive is only used by
|
||||
// `recall_tool_history` which already truncates individual results to 50K chars,
|
||||
// so retaining the most recent entries is sufficient for lookup.
|
||||
const MAX_TOOL_RESULT_ARCHIVE_ENTRIES: usize = 400;
|
||||
|
||||
let mut pending_tool_uses: Vec<crate::ai::provider::convert::ConversationMessage> =
|
||||
Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
match &msg.content {
|
||||
MessageContent::ToolUse { .. } => pending_tool_uses.push(msg),
|
||||
MessageContent::ToolResult { .. } => {
|
||||
if !pending_tool_uses.is_empty() {
|
||||
let tool_use = pending_tool_uses.remove(pending_tool_uses.len() - 1);
|
||||
self.tool_result_archive.push(tool_use);
|
||||
}
|
||||
self.tool_result_archive.push(msg);
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
for part in parts {
|
||||
match part {
|
||||
ContentPart::ToolUse { .. } => {
|
||||
pending_tool_uses.push(msg.clone());
|
||||
}
|
||||
ContentPart::ToolResult { .. } => {
|
||||
if !pending_tool_uses.is_empty() {
|
||||
let tool_use =
|
||||
pending_tool_uses.remove(pending_tool_uses.len() - 1);
|
||||
self.tool_result_archive.push(tool_use);
|
||||
}
|
||||
self.tool_result_archive.push(msg.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for tool_use in pending_tool_uses {
|
||||
self.tool_result_archive.push(tool_use);
|
||||
}
|
||||
|
||||
// Evict oldest entries if the archive exceeds the cap.
|
||||
if self.tool_result_archive.len() > MAX_TOOL_RESULT_ARCHIVE_ENTRIES {
|
||||
let excess = self.tool_result_archive.len() - MAX_TOOL_RESULT_ARCHIVE_ENTRIES;
|
||||
self.tool_result_archive.drain(0..excess);
|
||||
}
|
||||
galaxy_agent_core::archive_tool_results(&mut self.tool_result_archive, messages);
|
||||
}
|
||||
|
||||
pub fn append_to_bedrock_history(
|
||||
|
||||
@@ -27,12 +27,21 @@ use crate::features::FeatureFlag;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
#[path = "search_codebase_context.rs"]
|
||||
mod context;
|
||||
use context::{bound_search_result, MAX_SEARCH_FILES, MAX_SEARCH_SCAN_BYTES};
|
||||
|
||||
struct PendingSearch {
|
||||
sender: oneshot::Sender<SearchCodebaseResult>,
|
||||
query: String,
|
||||
}
|
||||
|
||||
pub struct SearchCodebaseExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
get_relevant_files_controller: ModelHandle<GetRelevantFilesController>,
|
||||
/// Per-action response channels for searches that are still waiting on
|
||||
/// `GetRelevantFilesController`.
|
||||
active_searches: HashMap<AIAgentActionId, oneshot::Sender<SearchCodebaseResult>>,
|
||||
active_searches: HashMap<AIAgentActionId, PendingSearch>,
|
||||
/// Cached repo roots derived during preprocessing so permission checks and execution can agree
|
||||
/// on which repository the action actually targets.
|
||||
root_repo_paths: HashMap<AIAgentActionId, PathBuf>,
|
||||
@@ -59,9 +68,7 @@ impl SearchCodebaseExecutor {
|
||||
let Some(result_tx) = me.active_searches.remove(action_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = result_tx.send(result.clone()) {
|
||||
log::warn!("Failed to send search codebase results to receiver {e:?}.");
|
||||
}
|
||||
Self::send_bounded_result(result_tx, result.clone(), ctx);
|
||||
}
|
||||
GetRelevantFilesControllerEvent::Success {
|
||||
result: GetRelevantFilesControllerResult::Locations(fragments),
|
||||
@@ -70,6 +77,8 @@ impl SearchCodebaseExecutor {
|
||||
let action_id = event.action_id().clone();
|
||||
let locations = fragments
|
||||
.iter()
|
||||
.sorted_by_key(|location| location.path())
|
||||
.take(MAX_SEARCH_FILES)
|
||||
.map(|location| location.into())
|
||||
.collect_vec();
|
||||
let current_working_directory = me
|
||||
@@ -84,13 +93,15 @@ impl SearchCodebaseExecutor {
|
||||
&locations,
|
||||
current_working_directory,
|
||||
shell,
|
||||
None,
|
||||
None,
|
||||
Some(MAX_SEARCH_SCAN_BYTES),
|
||||
Some(MAX_SEARCH_SCAN_BYTES * MAX_SEARCH_FILES),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if !result.missing_files.is_empty() {
|
||||
if result.file_contexts.is_empty()
|
||||
&& !result.missing_files.is_empty()
|
||||
{
|
||||
let missing_files = result.missing_files.join(", ");
|
||||
SearchCodebaseResult::Failed {
|
||||
message: format!(
|
||||
@@ -110,15 +121,11 @@ impl SearchCodebaseExecutor {
|
||||
},
|
||||
}
|
||||
},
|
||||
move |me, result, _| {
|
||||
move |me, result, ctx| {
|
||||
let Some(result_tx) = me.active_searches.remove(&action_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = result_tx.send(result) {
|
||||
log::warn!(
|
||||
"Failed to send search codebase results to receiver {e:?}."
|
||||
);
|
||||
}
|
||||
Self::send_bounded_result(result_tx, result, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -126,7 +133,7 @@ impl SearchCodebaseExecutor {
|
||||
let Some(result_tx) = me.active_searches.remove(action_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = result_tx.send(SearchCodebaseResult::Failed {
|
||||
if let Err(e) = result_tx.sender.send(SearchCodebaseResult::Failed {
|
||||
message: "The search failed. Try another way to locate the relevant files."
|
||||
.to_owned(),
|
||||
reason: SearchCodebaseFailureReason::GetRelevantFilesError,
|
||||
@@ -146,6 +153,21 @@ impl SearchCodebaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
fn send_bounded_result(
|
||||
pending: PendingSearch,
|
||||
result: SearchCodebaseResult,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.spawn(
|
||||
async move { (pending.sender, bound_search_result(result, &pending.query)) },
|
||||
|_, (sender, result), _| {
|
||||
if let Err(error) = sender.send(result) {
|
||||
log::debug!("Search result receiver was dropped: {error:?}");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
&self,
|
||||
input: ExecuteActionInput,
|
||||
@@ -245,7 +267,13 @@ impl SearchCodebaseExecutor {
|
||||
});
|
||||
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
self.active_searches.insert(
|
||||
id.clone(),
|
||||
PendingSearch {
|
||||
sender: result_tx,
|
||||
query: query.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
match self
|
||||
.get_relevant_files_controller
|
||||
@@ -358,7 +386,13 @@ impl SearchCodebaseExecutor {
|
||||
});
|
||||
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
self.active_searches.insert(
|
||||
id.clone(),
|
||||
PendingSearch {
|
||||
sender: result_tx,
|
||||
query: query.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// Start the actual search.
|
||||
match self
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Bounded discovery excerpts. Explicit `read_files` calls provide full follow-up context.
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(super) use ai::agent::action_result::MAX_SEARCH_CODEBASE_FILES as MAX_SEARCH_FILES;
|
||||
|
||||
use crate::ai::agent::{AnyFileContent, FileContext, SearchCodebaseResult};
|
||||
pub(super) const MAX_SEARCH_SCAN_BYTES: usize = 256_000;
|
||||
const MAX_EXCERPT_BYTES: usize = 4_000;
|
||||
const MAX_EXCERPT_LINES: usize = 40;
|
||||
|
||||
pub(super) fn bound_search_result(
|
||||
result: SearchCodebaseResult,
|
||||
query: &str,
|
||||
) -> SearchCodebaseResult {
|
||||
let SearchCodebaseResult::Success { files } = result else {
|
||||
return result;
|
||||
};
|
||||
let terms: Vec<_> = query
|
||||
.split(|character: char| !character.is_alphanumeric() && character != '_')
|
||||
.filter(|term| term.len() >= 3)
|
||||
.map(str::to_lowercase)
|
||||
.filter(|term| {
|
||||
!matches!(
|
||||
term.as_str(),
|
||||
"the"
|
||||
| "and"
|
||||
| "for"
|
||||
| "with"
|
||||
| "from"
|
||||
| "that"
|
||||
| "this"
|
||||
| "find"
|
||||
| "show"
|
||||
| "all"
|
||||
| "code"
|
||||
| "files"
|
||||
| "where"
|
||||
| "what"
|
||||
| "when"
|
||||
| "which"
|
||||
| "into"
|
||||
)
|
||||
})
|
||||
.take(32)
|
||||
.collect();
|
||||
let mut seen = HashSet::new();
|
||||
let files = files
|
||||
.into_iter()
|
||||
.filter(|file| seen.insert(file.file_name.clone()))
|
||||
.filter_map(|file| excerpt(file, &terms))
|
||||
.take(MAX_SEARCH_FILES)
|
||||
.collect();
|
||||
SearchCodebaseResult::Success { files }
|
||||
}
|
||||
|
||||
fn excerpt(mut file: FileContext, terms: &[String]) -> Option<FileContext> {
|
||||
let AnyFileContent::StringContent(content) = &file.content else {
|
||||
return None;
|
||||
};
|
||||
// Also bound remote payloads before scoring. Never split a UTF-8 code point.
|
||||
let mut scan_end = content.len().min(MAX_SEARCH_SCAN_BYTES);
|
||||
while !content.is_char_boundary(scan_end) {
|
||||
scan_end -= 1;
|
||||
}
|
||||
let lines: Vec<_> = content[..scan_end].split_inclusive('\n').collect();
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut best_line = 0;
|
||||
let mut best_score = 0;
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let lower = line.to_lowercase();
|
||||
let score: usize = terms
|
||||
.iter()
|
||||
.filter(|term| lower.contains(term.as_str()))
|
||||
.map(String::len)
|
||||
.sum();
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_line = index;
|
||||
}
|
||||
}
|
||||
let mut start = best_line;
|
||||
let mut preceding_bytes = 0;
|
||||
while start > best_line.saturating_sub(5) {
|
||||
let length = lines[start - 1].len();
|
||||
if preceding_bytes + length > MAX_EXCERPT_BYTES / 3 {
|
||||
break;
|
||||
}
|
||||
preceding_bytes += length;
|
||||
start -= 1;
|
||||
}
|
||||
let mut text = String::new();
|
||||
let mut line_count: usize = 0;
|
||||
for line in lines.iter().skip(start).take(MAX_EXCERPT_LINES) {
|
||||
let remaining = MAX_EXCERPT_BYTES - text.len();
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let mut end = line.len().min(remaining);
|
||||
while !line.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
if end == 0 {
|
||||
break;
|
||||
}
|
||||
text.push_str(&line[..end]);
|
||||
line_count += 1;
|
||||
if end < line.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let first_line = file.line_range.as_ref().map_or(1, |range| range.start) + start;
|
||||
file.line_range = Some(first_line..first_line + line_count.saturating_sub(1));
|
||||
file.content = AnyFileContent::StringContent(text);
|
||||
Some(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "search_codebase_context_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
|
||||
fn file(name: &str, text: String) -> FileContext {
|
||||
FileContext::new(
|
||||
name.to_string(),
|
||||
AnyFileContent::StringContent(text),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_returns_bounded_unique_files_with_valid_utf8() {
|
||||
let files = (0..20)
|
||||
.flat_map(|index| {
|
||||
let file = file(&format!("{index}.rs"), "€".repeat(20_000));
|
||||
[file.clone(), file]
|
||||
})
|
||||
.collect();
|
||||
let SearchCodebaseResult::Success { files } =
|
||||
bound_search_result(SearchCodebaseResult::Success { files }, "find models")
|
||||
else {
|
||||
panic!("expected success");
|
||||
};
|
||||
assert_eq!(files.len(), MAX_SEARCH_FILES);
|
||||
let mut bytes = 0;
|
||||
for file in files {
|
||||
let AnyFileContent::StringContent(text) = file.content else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert!(text.len() <= MAX_EXCERPT_BYTES);
|
||||
assert_eq!(text.len() % 3, 0);
|
||||
bytes += text.len();
|
||||
}
|
||||
assert!(bytes <= MAX_SEARCH_FILES * MAX_EXCERPT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excerpt_finds_a_later_match_and_preserves_source_line_numbers() {
|
||||
let text = format!(
|
||||
"{}fn construct_chat_model() {{}}\n{}",
|
||||
"// unrelated\n".repeat(200),
|
||||
"// trailing\n".repeat(100)
|
||||
);
|
||||
let mut input = file("models.rs", text);
|
||||
input.line_range = Some(101..401);
|
||||
let output = excerpt(input, &["construct_chat_model".into()]).unwrap();
|
||||
assert_eq!(output.line_range, Some(296..335));
|
||||
let AnyFileContent::StringContent(text) = output.content else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert!(text.contains("construct_chat_model"));
|
||||
assert_eq!(text.lines().count(), MAX_EXCERPT_LINES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_preceding_line_does_not_hide_the_relevant_match() {
|
||||
let input = file(
|
||||
"models.rs",
|
||||
format!("{}\nfn target_model() {{}}\n", "x".repeat(10_000)),
|
||||
);
|
||||
let output = excerpt(input, &["target_model".into()]).unwrap();
|
||||
assert_eq!(output.line_range, Some(2..2));
|
||||
let AnyFileContent::StringContent(text) = output.content else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert!(text.contains("target_model"));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -13,12 +14,13 @@ use warpui::r#async::Timer;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SendMessageToAgentResult,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||
use crate::ai::blocklist::telemetry::{
|
||||
BlocklistOrchestrationTelemetryEvent, TeamAgentCommunicationFailedEvent,
|
||||
TeamAgentCommunicationFailureReason, TeamAgentCommunicationKind,
|
||||
@@ -32,6 +34,7 @@ const SEND_AGENT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct SendMessageToAgentExecutor {
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
local_recovery_attempts: HashMap<AIConversationId, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -121,6 +124,7 @@ impl SendMessageToAgentExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ambient_agent_task_id: None,
|
||||
local_recovery_attempts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +163,68 @@ impl SendMessageToAgentExecutor {
|
||||
let subject = subject.clone();
|
||||
let message_body = message.clone();
|
||||
|
||||
// Resolve only an actual child of this sender; local routing never trusts an arbitrary
|
||||
// conversation ID as authority to resume another task.
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let local_children = history.child_conversations_of(conversation_id);
|
||||
let local_child = if addresses.len() == 1 {
|
||||
local_children.iter().find(|child| {
|
||||
!child.is_remote_child()
|
||||
&& (child.id().to_string() == addresses[0]
|
||||
|| child.orchestration_agent_id().as_deref() == Some(addresses[0].as_str()))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(child) = local_child {
|
||||
let child_id = child.id();
|
||||
let error = if message_body.trim().is_empty() {
|
||||
Some("A corrective instruction or follow-up message is required.")
|
||||
} else if matches!(child.status(), ConversationStatus::Cancelled) {
|
||||
Some("The child was cancelled. Ask the user to resume it; do not override cancellation.")
|
||||
} else if matches!(child.status(), ConversationStatus::Blocked { .. }) {
|
||||
Some("The child is blocked on permission or user input. Resolve that block rather than restarting it.")
|
||||
} else if matches!(child.status(), ConversationStatus::Error)
|
||||
&& self
|
||||
.local_recovery_attempts
|
||||
.get(&child_id)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
>= 2
|
||||
{
|
||||
Some("This child has exhausted two orchestrator recovery attempts. Report the blocker to the user.")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(error) = error {
|
||||
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Error(error.to_string()),
|
||||
))
|
||||
.into();
|
||||
}
|
||||
if matches!(child.status(), ConversationStatus::Error) {
|
||||
*self.local_recovery_attempts.entry(child_id).or_default() += 1;
|
||||
}
|
||||
let message_id = OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| {
|
||||
service.send_local_follow_up(conversation_id, child_id, message_body, ctx)
|
||||
});
|
||||
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Success { message_id },
|
||||
))
|
||||
.into();
|
||||
}
|
||||
if history
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|parent| parent.run_id().is_none())
|
||||
|| addresses.iter().any(|address| {
|
||||
history.all_live_conversations().iter().any(|(_, target)| {
|
||||
!target.is_remote_child() && target.id().to_string() == *address
|
||||
})
|
||||
})
|
||||
{
|
||||
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(SendMessageToAgentResult::Error("The target must be one existing child of this orchestrator. Use its agent_id from run_agents; do not message unrelated conversations.".to_string()))).into();
|
||||
}
|
||||
|
||||
let (sender_run_id, task_id, task_resolution) =
|
||||
sender_run_id_and_task_id_for_send(conversation_id, self.ambient_agent_task_id, ctx);
|
||||
let log_addresses = addresses.clone();
|
||||
|
||||
@@ -313,6 +313,11 @@ impl StartAgentExecutor {
|
||||
let Some(pending) = self.pending.remove(&request_id) else {
|
||||
return;
|
||||
};
|
||||
let error_msg = if matches!(pending.wait_policy, StartAgentWaitPolicy::Completion) {
|
||||
format!("Child agent {child_conversation_id} failed: {error_msg}. For a recoverable error (never a user cancellation), use send_message_to_agent with this agent_id and a corrective instruction to continue the same child; do not create a duplicate.")
|
||||
} else {
|
||||
error_msg
|
||||
};
|
||||
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
|
||||
// Only startup acknowledgements may clean up a conversation that never
|
||||
// initialized. Direct-provider completion waits preserve the terminal
|
||||
|
||||
@@ -3948,6 +3948,11 @@ impl BlocklistAIController {
|
||||
});
|
||||
}
|
||||
|
||||
if has_piggybacked_events && result.is_ok() {
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, _| {
|
||||
svc.confirm_local_messages(conversation_id);
|
||||
});
|
||||
}
|
||||
self.pending_passive_follow_ups.remove(&conversation_id);
|
||||
}
|
||||
|
||||
@@ -4102,8 +4107,13 @@ impl BlocklistAIController {
|
||||
let is_ready_status = matches!(
|
||||
conversation.status(),
|
||||
ConversationStatus::Success | ConversationStatus::WaitingForEvents,
|
||||
);
|
||||
if !owns || has_active_stream || !is_ready_status {
|
||||
) || (matches!(conversation.status(), ConversationStatus::Error)
|
||||
&& OrchestrationEventService::as_ref(ctx).has_pending_local_message(conversation_id));
|
||||
// A direct-provider wait can yield to a queued successor. send_request_input
|
||||
// checkpoints that successor and cancels the old generation before resuming.
|
||||
let provider_wait = self.active_provider_runs.contains_key(&conversation_id)
|
||||
&& matches!(conversation.status(), ConversationStatus::WaitingForEvents);
|
||||
if !owns || (has_active_stream && !provider_wait) || !is_ready_status {
|
||||
log::info!(
|
||||
"Pending events are not ready: conversation_id={conversation_id:?} owns_conversation={owns} has_active_stream={has_active_stream} status={:?}",
|
||||
conversation.status()
|
||||
@@ -4316,6 +4326,17 @@ impl BlocklistAIController {
|
||||
return;
|
||||
};
|
||||
|
||||
// A provider wait owns a durable run. Terminalize that generation before
|
||||
// queuing the message turn, just as for a same-conversation follow-up.
|
||||
if self.active_provider_runs.contains_key(&conversation_id) {
|
||||
self.cancel_active_provider_run(
|
||||
conversation_id,
|
||||
CancellationReason::FollowUpSubmitted {
|
||||
is_for_same_conversation: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
// The resume request supersedes any in-flight wait_for_events.
|
||||
self.action_model.update(ctx, |action_model, ctx| {
|
||||
action_model.cancel_wait_for_events_for_conversation(conversation_id, ctx);
|
||||
@@ -4349,6 +4370,10 @@ impl BlocklistAIController {
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.requeue_awaiting_events(conversation_id, ctx);
|
||||
});
|
||||
} else {
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, _| {
|
||||
svc.confirm_local_messages(conversation_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8372,6 +8397,9 @@ impl BlocklistAIController {
|
||||
});
|
||||
self.maybe_refresh_ai_overages(ctx);
|
||||
self.start_next_queued_provider_run(conversation_id, ctx);
|
||||
// Status notifications may arrive before the stream releases its slot.
|
||||
// Retry queued child messages now that terminal cleanup is complete.
|
||||
self.handle_pending_events_ready(conversation_id, ctx);
|
||||
}
|
||||
|
||||
fn start_next_queued_provider_run(
|
||||
|
||||
@@ -47,6 +47,12 @@ impl LifecycleEventDetailStage {
|
||||
/// Type-specific queued data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PendingEventDetail {
|
||||
/// A locally authorized parent/child message; never sent through the hosted messaging API.
|
||||
LocalMessage {
|
||||
message_id: String,
|
||||
subject: String,
|
||||
message_body: String,
|
||||
},
|
||||
Message {
|
||||
message_id: String,
|
||||
addresses: Vec<String>,
|
||||
@@ -90,6 +96,7 @@ pub struct OrchestrationEventService {
|
||||
pending_events: HashMap<AIConversationId, Vec<PendingEvent>>,
|
||||
awaiting_server_echo_events: HashMap<AIConversationId, Vec<PendingEvent>>,
|
||||
conversation_statuses: HashMap<AIConversationId, ConversationStatus>,
|
||||
restarted_children: HashSet<AIConversationId>,
|
||||
}
|
||||
|
||||
impl OrchestrationEventService {
|
||||
@@ -106,6 +113,7 @@ impl OrchestrationEventService {
|
||||
pending_events: HashMap::new(),
|
||||
awaiting_server_echo_events: HashMap::new(),
|
||||
conversation_statuses: HashMap::new(),
|
||||
restarted_children: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +156,7 @@ impl OrchestrationEventService {
|
||||
self.pending_events.remove(conversation_id);
|
||||
self.awaiting_server_echo_events.remove(conversation_id);
|
||||
self.conversation_statuses.remove(conversation_id);
|
||||
self.restarted_children.remove(conversation_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -184,8 +193,12 @@ impl OrchestrationEventService {
|
||||
conversation.status().clone()
|
||||
};
|
||||
|
||||
self.conversation_statuses
|
||||
let previous = self
|
||||
.conversation_statuses
|
||||
.insert(conversation_id, current_status.clone());
|
||||
if !is_restored && previous.as_ref() != Some(¤t_status) {
|
||||
self.report_restarted_child(conversation_id, previous.as_ref(), ¤t_status, ctx);
|
||||
}
|
||||
let has_pending = self
|
||||
.pending_events
|
||||
.get(&conversation_id)
|
||||
@@ -196,16 +209,148 @@ impl OrchestrationEventService {
|
||||
// the stream finishes — either to `Success` or to
|
||||
// `WaitingForEvents` via a `wait_for_events` yield.
|
||||
if !is_restored
|
||||
&& matches!(
|
||||
&& (matches!(
|
||||
¤t_status,
|
||||
ConversationStatus::Success | ConversationStatus::WaitingForEvents
|
||||
)
|
||||
) || (matches!(current_status, ConversationStatus::Error)
|
||||
&& self.has_pending_local_message(conversation_id)))
|
||||
&& has_pending
|
||||
{
|
||||
ctx.emit(OrchestrationEventServiceEvent::EventsReady { conversation_id });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_pending_local_message(&self, conversation_id: AIConversationId) -> bool {
|
||||
self.pending_events
|
||||
.get(&conversation_id)
|
||||
.is_some_and(|events| {
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event.detail, PendingEventDetail::LocalMessage { .. }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Local providers commit the input directly and do not emit a hosted message echo.
|
||||
pub fn confirm_local_messages(&mut self, conversation_id: AIConversationId) {
|
||||
if let Some(events) = self.awaiting_server_echo_events.get_mut(&conversation_id) {
|
||||
events.retain(|event| !matches!(event.detail, PendingEventDetail::LocalMessage { .. }));
|
||||
if events.is_empty() {
|
||||
self.awaiting_server_echo_events.remove(&conversation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_local_follow_up(
|
||||
&mut self,
|
||||
parent: AIConversationId,
|
||||
child: AIConversationId,
|
||||
message: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> String {
|
||||
self.restarted_children.insert(child);
|
||||
self.enqueue_local_message(
|
||||
child,
|
||||
parent,
|
||||
"Follow-up from your orchestrator",
|
||||
message,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
fn enqueue_local_message(
|
||||
&mut self,
|
||||
target: AIConversationId,
|
||||
source: AIConversationId,
|
||||
subject: &str,
|
||||
message_body: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> String {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
self.enqueue_event_batch(
|
||||
target,
|
||||
vec![PendingEvent {
|
||||
event_id: message_id.clone(),
|
||||
source_agent_id: source.to_string(),
|
||||
attempt_count: 0,
|
||||
detail: PendingEventDetail::LocalMessage {
|
||||
message_id: message_id.clone(),
|
||||
subject: subject.to_string(),
|
||||
message_body,
|
||||
},
|
||||
}],
|
||||
ctx,
|
||||
);
|
||||
message_id
|
||||
}
|
||||
|
||||
fn report_restarted_child(
|
||||
&mut self,
|
||||
child: AIConversationId,
|
||||
previous: Option<&ConversationStatus>,
|
||||
status: &ConversationStatus,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(&child) else {
|
||||
return;
|
||||
};
|
||||
if conversation.is_remote_child() {
|
||||
return;
|
||||
}
|
||||
let Some(parent) = conversation.parent_conversation_id() else {
|
||||
return;
|
||||
};
|
||||
let restarting = matches!(
|
||||
previous,
|
||||
Some(
|
||||
ConversationStatus::Error
|
||||
| ConversationStatus::Cancelled
|
||||
| ConversationStatus::Success
|
||||
)
|
||||
) && matches!(
|
||||
status,
|
||||
ConversationStatus::InProgress | ConversationStatus::TransientError
|
||||
);
|
||||
if restarting {
|
||||
self.restarted_children.insert(child);
|
||||
}
|
||||
if !self.restarted_children.contains(&child) {
|
||||
return;
|
||||
}
|
||||
let name = conversation.agent_name().unwrap_or("child agent");
|
||||
let message = if restarting {
|
||||
format!("Child {name} ({child}) has restarted. Its earlier terminal result is no longer the final outcome. Keep tracking this child; do not spawn a replacement. A completion update will follow.")
|
||||
} else if matches!(status, ConversationStatus::Success) {
|
||||
let output = conversation
|
||||
.all_exchanges()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|exchange| exchange.output_status.output())
|
||||
.map(|output| output.get().format_for_copy(None))
|
||||
.find(|text| !text.is_empty())
|
||||
.unwrap_or_default();
|
||||
let mut end = output.len().min(16_000);
|
||||
while !output.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("Restarted child {name} ({child}) completed successfully. Incorporate this result into your task, even if an earlier attempt failed.\n{}{}", &output[..end], if end < output.len() { "\n[Output excerpt truncated; inspect the child conversation for more.]" } else { "" })
|
||||
} else if matches!(
|
||||
status,
|
||||
ConversationStatus::Error | ConversationStatus::Cancelled
|
||||
) {
|
||||
format!("Restarted child {name} ({child}) ended with {status:?}: {}. Diagnose the failure before retrying; do not loop or override a user cancellation.", conversation.status_error_message().unwrap_or_default())
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if matches!(
|
||||
status,
|
||||
ConversationStatus::Success | ConversationStatus::Error | ConversationStatus::Cancelled
|
||||
) {
|
||||
self.restarted_children.remove(&child);
|
||||
}
|
||||
self.enqueue_local_message(parent, child, "Child agent progress update", message, ctx);
|
||||
}
|
||||
|
||||
fn enqueue_lifecycle_event(
|
||||
&mut self,
|
||||
target_conversation_id: AIConversationId,
|
||||
@@ -305,6 +450,20 @@ impl OrchestrationEventService {
|
||||
let mut server_bound_events = Vec::new();
|
||||
for event in deliverable {
|
||||
match &event.detail {
|
||||
PendingEventDetail::LocalMessage {
|
||||
message_id,
|
||||
subject,
|
||||
message_body,
|
||||
} => {
|
||||
messages.push(ReceivedMessageInput {
|
||||
message_id: message_id.clone(),
|
||||
sender_agent_id: event.source_agent_id.clone(),
|
||||
addresses: vec![conversation_id.to_string()],
|
||||
subject: subject.clone(),
|
||||
message_body: message_body.clone(),
|
||||
});
|
||||
server_bound_events.push(event);
|
||||
}
|
||||
PendingEventDetail::Message {
|
||||
message_id,
|
||||
addresses,
|
||||
@@ -625,7 +784,8 @@ fn did_event_round_trip_through_server(
|
||||
echoed_lifecycle_event_ids: &HashSet<&str>,
|
||||
) -> bool {
|
||||
match &pending_event.detail {
|
||||
PendingEventDetail::Message { message_id, .. } => {
|
||||
PendingEventDetail::Message { message_id, .. }
|
||||
| PendingEventDetail::LocalMessage { message_id, .. } => {
|
||||
echoed_message_ids.contains(message_id.as_str())
|
||||
}
|
||||
PendingEventDetail::Lifecycle { event } => {
|
||||
|
||||
@@ -355,3 +355,39 @@ fn test_has_pending_events_tracks_any_event_kind() {
|
||||
service.pending_events.remove(&conversation_id);
|
||||
assert!(!service.has_pending_events(conversation_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_follow_up_is_delivered_once_and_acknowledged_without_server_echo() {
|
||||
let conversation_id = AIConversationId::new();
|
||||
let mut service = OrchestrationEventService::new_without_subscriptions();
|
||||
service.pending_events.insert(
|
||||
conversation_id,
|
||||
vec![
|
||||
PendingEvent {
|
||||
event_id: "local-1".to_string(),
|
||||
source_agent_id: "parent".to_string(),
|
||||
attempt_count: 0,
|
||||
detail: PendingEventDetail::LocalMessage {
|
||||
message_id: "local-1".to_string(),
|
||||
subject: "Correction".to_string(),
|
||||
message_body: "Continue the existing task.".to_string(),
|
||||
},
|
||||
},
|
||||
message_pending_event("hosted-1"),
|
||||
],
|
||||
);
|
||||
assert!(service.has_pending_local_message(conversation_id));
|
||||
let inputs = service.drain_and_convert_events(conversation_id);
|
||||
let AIAgentInput::MessagesReceivedFromAgents { messages } = &inputs[0] else {
|
||||
panic!("Expected child follow-up input");
|
||||
};
|
||||
assert_eq!(messages[0].sender_agent_id, "parent");
|
||||
assert_eq!(messages[0].message_body, "Continue the existing task.");
|
||||
assert_eq!(messages[0].addresses, vec![conversation_id.to_string()]);
|
||||
assert!(service.drain_and_convert_events(conversation_id).is_empty());
|
||||
service.confirm_local_messages(conversation_id);
|
||||
let awaiting = &service.awaiting_server_echo_events[&conversation_id];
|
||||
assert_eq!(awaiting.len(), 1);
|
||||
assert_eq!(awaiting[0].event_id, "hosted-1");
|
||||
assert!(!service.has_pending_local_message(conversation_id));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use instant::Instant;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent::SearchCodebaseFailureReason;
|
||||
use crate::ai::agent::{AIAgentActionId, SearchCodebaseResult};
|
||||
use crate::ai::agent::{AIAgentActionId, SearchCodebaseResult, MAX_SEARCH_CODEBASE_FILES};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
||||
use crate::ai::get_relevant_files::api::{FileContext as FileContextRequest, GetRelevantFiles};
|
||||
@@ -362,6 +362,7 @@ impl GetRelevantFilesController {
|
||||
.search(directory, &query, partial_path_segments.map(Vec::as_slice))
|
||||
.map_err(|error| LocalIndexUnavailable(error.to_string()))?
|
||||
.into_iter()
|
||||
.take(MAX_SEARCH_CODEBASE_FILES)
|
||||
.map(|hit| CodeContextLocation::WholeFile(hit.path))
|
||||
.collect();
|
||||
let file_paths = locations
|
||||
|
||||
@@ -686,6 +686,7 @@ const KNOWN_TOOLS: &[&str] = &[
|
||||
"edit_documents",
|
||||
"run_agents",
|
||||
"wait_for_events",
|
||||
"send_message_to_agent",
|
||||
"start_agent",
|
||||
"ask_user_question",
|
||||
"read_skill",
|
||||
|
||||
@@ -1560,7 +1560,7 @@ pub fn extract_system_prompt(
|
||||
let has_tool = |name: &str| tool_names.iter().any(|tool_name| tool_name == name);
|
||||
if has_tool("search_codebase") {
|
||||
prompt.push_str(
|
||||
"- For source-code discovery, semantic questions, or finding an unfamiliar implementation, MUST use `search_codebase` first. Never use `grep` to discover or search source code when `search_codebase` is available; reserve `grep` for exact known text in non-code files. Use `file_glob` only to locate filenames and `read_files` for focused follow-up context.\n",
|
||||
"- For source-code discovery, semantic questions, or finding an unfamiliar implementation, MUST use `search_codebase` first. Never use `grep` to discover or search source code when `search_codebase` is available; reserve `grep` for exact known text in non-code files. Use `file_glob` only to locate filenames and `read_files` for focused follow-up context. Start with one concise search query using distinctive terms; avoid parallel rephrasings over the same scope. Search returns at most eight bounded excerpts, not an exhaustive audit. Read the relevant files next and only broaden or narrow the search when the results leave a specific gap.\n",
|
||||
);
|
||||
}
|
||||
if [
|
||||
@@ -1701,7 +1701,9 @@ pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolTy
|
||||
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
|
||||
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
|
||||
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
|
||||
"run_agents" | "start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
|
||||
"run_agents" | "start_agent" | "send_message_to_agent" | "wait_for_events" => {
|
||||
has(ToolType::Subagent) || has(ToolType::StartAgentV2)
|
||||
}
|
||||
"ask_user_question" => has(ToolType::AskUserQuestion),
|
||||
"read_skill" => has(ToolType::ReadSkill),
|
||||
"fetch_conversation" => has(ToolType::FetchConversation),
|
||||
@@ -2030,9 +2032,15 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"required": ["name", "prompt"]
|
||||
}),
|
||||
},
|
||||
// send_message_to_agent removed — child agents in local/Bedrock mode
|
||||
// run autonomously and cannot receive messages. Exposing this tool
|
||||
// causes the model to poll in a loop.
|
||||
ToolDefinition {
|
||||
name: "send_message_to_agent".to_string(),
|
||||
description: "Continue an existing child agent with a corrective instruction or follow-up. Use the child agent_id returned by run_agents, including IDs in failure messages. A failed local child resumes with its existing context; at most two recovery attempts are allowed. Diagnose the error first, avoid blind retries, never override cancellation or permission blocks, and never spawn a duplicate replacement. Delivery is not completion: use wait_for_events for the child's updated result.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object", "properties": {
|
||||
"agent_id": {"type": "string"}, "message": {"type": "string"}
|
||||
}, "required": ["agent_id", "message"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "ask_user_question".to_string(),
|
||||
description: "Ask the user a question when you need clarification or a decision. Present clear options when possible. Use sparingly — prefer making reasonable assumptions.".to_string(),
|
||||
@@ -2276,6 +2284,19 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
|
||||
None => ("MCP tool completed.".to_string(), false),
|
||||
}
|
||||
}
|
||||
api::request::input::tool_call_result::Result::SendMessageToAgent(result) => {
|
||||
match &result.result {
|
||||
Some(api::send_message_to_agent_result::Result::Success(success)) => (
|
||||
format!("Follow-up queued (message {}). Delivery is not completion; use wait_for_events for the child's updated result.", success.message_id),
|
||||
false,
|
||||
),
|
||||
Some(api::send_message_to_agent_result::Result::Error(error)) => (
|
||||
format!("Agent message failed: {}", error.message),
|
||||
true,
|
||||
),
|
||||
None => ("Agent message returned no delivery result.".to_string(), true),
|
||||
}
|
||||
}
|
||||
api::request::input::tool_call_result::Result::SearchCodebase(search_result) => {
|
||||
match &search_result.result {
|
||||
Some(api::search_codebase_result::Result::Success(success)) => (
|
||||
|
||||
@@ -101,7 +101,9 @@ fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
|
||||
"run_shell_command",
|
||||
"read_files",
|
||||
"run_agents",
|
||||
"wait_for_events",
|
||||
"start_agent",
|
||||
"send_message_to_agent",
|
||||
"recall_tool_history"
|
||||
]
|
||||
);
|
||||
@@ -680,3 +682,26 @@ fn injected_user_query_persists_images_for_session_restore() {
|
||||
if data == b"\x89PNG\r\n\x1a\npersisted" && mime_type == "image/png"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_capabilities_do_not_advertise_child_recovery_or_delegation() {
|
||||
let request = api::Request {
|
||||
settings: Some(api::request::Settings {
|
||||
supported_tools: vec![api::ToolType::ReadFiles.into()],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let names = extract_tools(&request)
|
||||
.into_iter()
|
||||
.map(|tool| tool.name)
|
||||
.collect::<Vec<_>>();
|
||||
for name in [
|
||||
"run_agents",
|
||||
"start_agent",
|
||||
"send_message_to_agent",
|
||||
"wait_for_events",
|
||||
] {
|
||||
assert!(!names.iter().any(|advertised| advertised == name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1619,6 +1619,7 @@ const KNOWN_TOOLS: &[&str] = &[
|
||||
"edit_documents",
|
||||
"run_agents",
|
||||
"wait_for_events",
|
||||
"send_message_to_agent",
|
||||
"start_agent",
|
||||
"ask_user_question",
|
||||
"read_skill",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! exclusively by local settings and should only receive operational metadata:
|
||||
//! provider/model IDs, lifecycle states, counts, timings, and sanitized errors.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
#[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))]
|
||||
use std::{
|
||||
@@ -25,6 +26,13 @@ use crate::AISettings;
|
||||
const DEFAULT_LOGS_PATH: &str = "/api/logs";
|
||||
const REMOTE_LOG_SERVICE: &str = "galaxy-ai";
|
||||
const REMOTE_LOG_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
// Authentication and endpoint selection remain scoped to each request.
|
||||
static REMOTE_LOG_CLIENT: LazyLock<Result<reqwest::Client, String>> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(REMOTE_LOG_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|error| format!("could not create HTTP client: {error}"))
|
||||
});
|
||||
const MAX_ERROR_CHARS: usize = 500;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -225,10 +233,7 @@ fn app_context() -> Value {
|
||||
}
|
||||
|
||||
async fn send_remote_log(config: RemoteLogConfig, payload: RemoteLogPayload) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REMOTE_LOG_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|error| format!("could not create HTTP client: {error}"))?;
|
||||
let client = REMOTE_LOG_CLIENT.as_ref().map_err(Clone::clone)?;
|
||||
|
||||
let response = client
|
||||
.post(&config.endpoint)
|
||||
|
||||
@@ -924,7 +924,7 @@ fn build_system_prompt(
|
||||
let has_search_codebase = tools.iter().any(|tool| tool.name == "search_codebase");
|
||||
if has_search_codebase {
|
||||
prompt.push_str(
|
||||
"For source-code discovery, semantic questions, or finding an unfamiliar implementation, MUST use `search_codebase` first. Never use `grep` to discover or search source code when `search_codebase` is available; reserve `grep` for exact known text in non-code files. Use `file_glob` only to locate filenames and `read_files` for focused follow-up context.\n",
|
||||
"For source-code discovery, semantic questions, or finding an unfamiliar implementation, MUST use `search_codebase` first. Never use `grep` to discover or search source code when `search_codebase` is available; reserve `grep` for exact known text in non-code files. Use `file_glob` only to locate filenames and `read_files` for focused follow-up context. Start with one concise search query using distinctive terms; avoid parallel rephrasings over the same scope. Search returns at most eight bounded excerpts, not an exhaustive audit. Read the relevant files next and only broaden or narrow the search when the results leave a specific gap.\n",
|
||||
);
|
||||
}
|
||||
if tools.iter().any(|tool| tool.name == "run_shell_command") {
|
||||
|
||||
@@ -6949,6 +6949,25 @@ impl PaneGroup {
|
||||
None
|
||||
}
|
||||
|
||||
/// Close only the child presentation; retain its live view and history for later inspection.
|
||||
pub fn hide_completed_child_agent(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(pane_id) = self.child_agent_panes.get(&conversation_id).copied() else {
|
||||
return;
|
||||
};
|
||||
if !self.panes.is_pane_in_tree(pane_id)
|
||||
&& self.panes.original_pane_for_replacement(pane_id).is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(pane_content) = self.take_child_agent_pane_for_split_off(conversation_id, ctx) {
|
||||
self.re_adopt_child_agent_pane(pane_content, conversation_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the pane that owns `conversation_id` the visible one in the
|
||||
/// focused pane's slot via temporary replacement. The previous occupant
|
||||
/// is restored on revert (back-button, ESC, pill-click, close, split-off).
|
||||
|
||||
@@ -196,6 +196,7 @@ fn initialize_app(app: &mut App) {
|
||||
app.add_singleton_model(voice_input::VoiceInput::new);
|
||||
#[cfg(feature = "local_fs")]
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(|_| ProjectContextModel::default());
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
app.add_singleton_model(ai::index::local_project_index::LocalProjectIndexManager::new);
|
||||
app.add_singleton_model(SkillManager::new);
|
||||
@@ -209,7 +210,6 @@ fn initialize_app(app: &mut App) {
|
||||
CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx)
|
||||
});
|
||||
app.add_singleton_model(|ctx| PersistedWorkspace::new(vec![], HashMap::new(), None, ctx));
|
||||
app.add_singleton_model(|_| ProjectContextModel::default());
|
||||
app.add_singleton_model(|ctx| crate::ai::agent_tips::AITipModel::new_for_agent_tips(ctx));
|
||||
app.add_singleton_model(|_| RestoredAgentConversations::new(vec![]));
|
||||
app.add_singleton_model(OneTimeModalModel::new);
|
||||
@@ -3388,3 +3388,63 @@ fn decide_remote_child_hydration_empty_token_falls_back() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_hiding_completed_child_preserves_parent_focus_and_reopen() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let pane_group = mock_pane_group(&mut app, Default::default());
|
||||
|
||||
pane_group.update(&mut app, |panes, ctx| {
|
||||
let parent_pane_id = get_newly_created_pane_id(panes, &[]);
|
||||
let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx);
|
||||
let child = create_hidden_child_agent_conversation(
|
||||
panes,
|
||||
HiddenChildAgentConversationRequest {
|
||||
parent_pane_id,
|
||||
name: "Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
orchestration_harness: None,
|
||||
env_vars: HashMap::new(),
|
||||
task_context: None,
|
||||
is_shared_session_creator: IsSharedSessionCreator::No,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.expect("fresh hidden child conversation should be created");
|
||||
let child_pane_id = panes
|
||||
.child_agent_panes
|
||||
.get(&child.conversation_id)
|
||||
.copied()
|
||||
.expect("fresh hidden child pane should be tracked");
|
||||
|
||||
assert_eq!(
|
||||
panes.unhide_child_agent_pane_for_split_off(child.conversation_id, ctx),
|
||||
Some(child_pane_id)
|
||||
);
|
||||
assert!(panes.panes.is_pane_in_tree(child_pane_id));
|
||||
|
||||
panes.hide_completed_child_agent(child.conversation_id, ctx);
|
||||
|
||||
assert_eq!(panes.focused_pane_id(ctx), parent_pane_id);
|
||||
assert!(panes.has_pane_id(child_pane_id));
|
||||
assert_eq!(
|
||||
panes.child_agent_panes.get(&child.conversation_id),
|
||||
Some(&child_pane_id)
|
||||
);
|
||||
assert!(!panes.panes.is_pane_in_tree(child_pane_id));
|
||||
|
||||
assert_eq!(
|
||||
panes.unhide_child_agent_pane_for_split_off(child.conversation_id, ctx),
|
||||
Some(child_pane_id)
|
||||
);
|
||||
assert_eq!(panes.focused_pane_id(ctx), child_pane_id);
|
||||
assert!(panes.panes.is_pane_in_tree(child_pane_id));
|
||||
assert!(panes
|
||||
.panes
|
||||
.pane_ids()
|
||||
.into_iter()
|
||||
.all(|pane_id| panes.has_pane_id(pane_id)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3580,6 +3580,23 @@ impl Workspace {
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
conversation_id,
|
||||
update,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if !matches!(
|
||||
update,
|
||||
crate::ai::blocklist::history_model::ConversationStatusUpdate::Restored
|
||||
) {
|
||||
let conversation_id = *conversation_id;
|
||||
// Defer pane movement until completion subscribers have captured the result.
|
||||
ctx.spawn(async {}, move |workspace, (), ctx| {
|
||||
workspace.close_completed_child_views(conversation_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
if self
|
||||
.current_workspace_state
|
||||
@@ -3624,6 +3641,58 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn close_completed_child_views(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
if conversation.parent_conversation_id().is_none()
|
||||
|| !matches!(
|
||||
conversation.status(),
|
||||
crate::ai::agent::conversation::ConversationStatus::Success
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(owner) = history.terminal_surface_id_for_conversation(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
// Do not close a pane the user has repurposed for a different conversation.
|
||||
if history
|
||||
.active_conversation(owner)
|
||||
.is_none_or(|active| active.id() != conversation_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let detached_tabs: Vec<_> = self
|
||||
.tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, tab)| {
|
||||
let group = tab.pane_group.as_ref(ctx);
|
||||
group
|
||||
.child_agent_origin()
|
||||
.is_some_and(|origin| origin.conversation_id == conversation_id)
|
||||
.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
for index in detached_tabs.into_iter().rev() {
|
||||
if self.tabs.len() > 1 && self.try_re_adopt_split_off_child_agent_tab(index, ctx) {
|
||||
self.remove_tab(index, false, false, ctx);
|
||||
}
|
||||
}
|
||||
let groups: Vec<_> = self.tabs.iter().map(|tab| tab.pane_group.clone()).collect();
|
||||
for group in groups {
|
||||
group.update(ctx, |group, ctx| {
|
||||
group.hide_completed_child_agent(conversation_id, ctx)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_contains_terminal_view(
|
||||
&self,
|
||||
terminal_view_id: EntityId,
|
||||
|
||||
Reference in New Issue
Block a user