Implement local sub-agent execution for OSS/Bedrock mode

- Fix start_agent tool mapping: route to Tool::StartAgent instead of
  dead-end Tool::Subagent so tool calls become executable actions
- Block start_agent until child finishes: parent waits for child
  conversation to complete and receives full output as tool result
- Fix tool result delivery: add StartAgent/StartAgentV2 cases to
  extract_tool_result_content so the model actually sees agent output
- Support parallel agent spawning: change StartAgent action phase from
  Serial to Parallel, and track multiple pending agents via Vec
- Mark child conversations as Success on EndTurn: emit
  ConversationStatus::Success when a child stream ends with no actions
- Skip orchestration SSE in local mode: prevent app freeze from trying
  to connect to non-existent server
- Remove send_message_to_agent and suggest_next_prompt from tool list:
  these require server infrastructure that doesn't exist in OSS mode
- Add "Waiting for sub-agents..." status message while agents process
- Fix child agent pane close: actually dismiss instead of re-hiding,
  track dismissed IDs to prevent re-creation on restart
- Fix cache_miss_tokens calculation and show per-block cache stats
- Add [tool-debug] logging throughout tool invocation pipeline

Bump version to 1.6.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-06-03 13:29:09 -05:00
co-authored by Claude Opus 4.6
parent 17ecf67970
commit a309006458
18 changed files with 458 additions and 114 deletions
+22 -1
View File
@@ -3320,7 +3320,28 @@ impl AIConversation {
pub fn cache_miss_tokens(&self) -> u32 {
self.total_token_usage_by_model
.values()
.map(|u| u.total_input)
.map(|u| u.total_input.saturating_sub(u.input_cache_read + u.input_cache_write))
.sum()
}
pub fn last_block_cache_read_tokens(&self) -> u32 {
self.last_block_token_usage_by_model
.values()
.map(|u| u.input_cache_read)
.sum()
}
pub fn last_block_cache_write_tokens(&self) -> u32 {
self.last_block_token_usage_by_model
.values()
.map(|u| u.input_cache_write)
.sum()
}
pub fn last_block_cache_miss_tokens(&self) -> u32 {
self.last_block_token_usage_by_model
.values()
.map(|u| u.total_input.saturating_sub(u.input_cache_read + u.input_cache_write))
.sum()
}
+30 -25
View File
@@ -1109,7 +1109,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
},
ToolDefinition {
name: "start_agent".to_string(),
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access.".to_string(),
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -1119,18 +1119,9 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
"required": ["name", "prompt"]
}),
},
ToolDefinition {
name: "send_message_to_agent".to_string(),
description: "Send a message to a running sub-agent. Use to provide additional context, ask for updates, or redirect the agent's work.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "ID of the target sub-agent" },
"message": { "type": "string", "description": "Message to send to the agent" }
},
"required": ["agent_id", "message"]
}),
},
// 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: "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(),
@@ -1143,18 +1134,10 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
"required": ["question"]
}),
},
ToolDefinition {
name: "suggest_next_prompt".to_string(),
description: "After completing a task, suggest a relevant follow-up action. Only call once at the end of your response. Keep labels concise and action-oriented.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "The full prompt text sent to the agent if the user clicks" },
"label": { "type": "string", "description": "Short display label (under 40 chars)" }
},
"required": ["prompt", "label"]
}),
},
// suggest_next_prompt removed — its executor hangs waiting for UI
// interaction that doesn't exist in the Bedrock path. The stream-level
// skip (response_translator) prevents deadlocks, but removing the tool
// definition avoids wasting output tokens on calls that will be discarded.
ToolDefinition {
name: "read_skill".to_string(),
description: "Read a skill definition to understand available capabilities and how to use them.".to_string(),
@@ -1398,6 +1381,28 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
}
None => ("Write to shell command completed.".to_string(), false),
},
api::request::input::tool_call_result::Result::StartAgent(start_agent_result) => {
match &start_agent_result.result {
Some(api::start_agent_result::Result::Success(success)) => {
(success.agent_id.clone(), false)
}
Some(api::start_agent_result::Result::Error(error)) => {
(format!("Agent error: {}", error.error), true)
}
None => ("Agent completed.".to_string(), false),
}
}
api::request::input::tool_call_result::Result::StartAgentV2(start_agent_result) => {
match &start_agent_result.result {
Some(api::start_agent_v2_result::Result::Success(success)) => {
(success.agent_id.clone(), false)
}
Some(api::start_agent_v2_result::Result::Error(error)) => {
(format!("Agent error: {}", error.error), true)
}
None => ("Agent completed.".to_string(), false),
}
}
_ => ("Tool completed successfully.".to_string(), false),
}
} else {
+28 -6
View File
@@ -104,6 +104,7 @@ pub fn bedrock_stream_to_response_events(
let mut current_tool_name = String::new();
let mut current_tool_input_json = String::new();
let mut _has_tool_calls = false;
let mut has_start_agent_calls = false;
let mut input_tokens: i32 = 0;
let mut output_tokens: i32 = 0;
let mut cache_read_input_tokens: i32 = 0;
@@ -275,6 +276,9 @@ pub fn bedrock_stream_to_response_events(
current_tool_name, current_tool_use_id, current_tool_input_json
));
}
if current_tool_name == "start_agent" {
has_start_agent_calls = true;
}
let tool_msg = build_tool_call_message(
&task_id,
&current_tool_use_id,
@@ -372,12 +376,29 @@ pub fn bedrock_stream_to_response_events(
if !text_flushed {
let add_msg = build_add_agent_output_message(&task_id, &msg_id, &buffered_text);
yield Ok(add_msg);
text_flushed = true;
current_text_message_id = Some(msg_id);
} else {
let append = build_append_text(&task_id, &msg_id, &buffered_text);
yield Ok(append);
}
}
// Show a status message while sub-agents are working so the user
// knows we're waiting on them.
if has_start_agent_calls {
let status_text = "\n\n*Waiting for sub-agents to finish, then I'll review and continue...*";
if text_flushed {
let msg_id = current_text_message_id.as_ref().unwrap();
let append = build_append_text(&task_id, msg_id, status_text);
yield Ok(append);
} else {
let msg_id = Uuid::new_v4().to_string();
let add_msg = build_add_agent_output_message(&task_id, &msg_id, status_text);
yield Ok(add_msg);
}
}
let cost = estimate_cost_cents(
input_tokens as u32,
output_tokens as u32,
@@ -985,13 +1006,14 @@ fn build_tool_call_message(
))
}
"start_agent" => {
let _name = input.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let prompt = input.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(api::message::tool_call::Tool::Subagent(
api::message::tool_call::Subagent {
task_id: String::new(),
payload: prompt,
metadata: None,
Some(api::message::tool_call::Tool::StartAgent(
api::StartAgent {
name,
prompt,
execution_mode: None,
lifecycle_subscription: None,
},
))
}
+47
View File
@@ -465,6 +465,16 @@ impl BlocklistAIActionModel {
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let pending_count = self
.pending_actions
.get(&conversation_id)
.map(|q| q.len())
.unwrap_or(0);
log::info!(
"[tool-debug] try_to_execute_available_actions: conversation={:?}, pending_count={}",
conversation_id,
pending_count
);
loop {
let Some(front_action) = self
.pending_actions
@@ -472,9 +482,16 @@ impl BlocklistAIActionModel {
.and_then(|queue| queue.front())
.cloned()
else {
log::info!("[tool-debug] try_to_execute_available_actions: no more pending actions");
return;
};
log::info!(
"[tool-debug] try_to_execute_available_actions: trying action id={:?}, type={:?}",
front_action.id,
std::mem::discriminant(&front_action.action)
);
if let Some(current_phase) = self.action_execution_phase(conversation_id) {
if !self.can_start_action_in_current_phase(
&front_action,
@@ -482,6 +499,10 @@ impl BlocklistAIActionModel {
current_phase,
ctx,
) {
log::info!(
"[tool-debug] try_to_execute_available_actions: cannot start in current phase {:?}",
current_phase
);
return;
}
}
@@ -489,15 +510,22 @@ impl BlocklistAIActionModel {
let Some(result) =
self.start_pending_action_by_id(&front_action.id, conversation_id, false, ctx)
else {
log::info!("[tool-debug] try_to_execute_available_actions: start_pending_action_by_id returned None (blocked)");
return;
};
log::info!(
"[tool-debug] try_to_execute_available_actions: action started, result={:?}",
std::mem::discriminant(&result)
);
if matches!(
result,
StartedAction::Async {
phase: RunningActionPhase::Serial
}
) {
log::info!("[tool-debug] try_to_execute_available_actions: serial async action, stopping loop");
return;
}
}
@@ -853,6 +881,19 @@ impl BlocklistAIActionModel {
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
log::info!(
"[tool-debug] queue_actions: queuing {} actions for conversation {:?}",
actions.len(),
conversation_id
);
for (i, action) in actions.iter().enumerate() {
log::info!(
"[tool-debug] queue_actions: [{}] id={:?}, type={:?}",
i,
action.id,
std::mem::discriminant(&action.action)
);
}
self.action_order.insert(
conversation_id,
actions
@@ -1135,6 +1176,12 @@ impl BlocklistAIActionModel {
cancellation_reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>,
) {
log::info!(
"[tool-debug] handle_action_result: action_id={:?}, result_type={:?}, cancellation={:?}",
action_result.id,
std::mem::discriminant(&action_result.result),
cancellation_reason
);
let should_remove_entry =
self.running_actions
.get_mut(&conversation_id)
+50 -4
View File
@@ -405,6 +405,9 @@ impl BlocklistAIActionExecutor {
{
RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext)
}
AIAgentActionType::StartAgent { .. } => {
RunningActionPhase::Parallel(ParallelExecutionPolicy::ReadOnlyLocalContext)
}
_ => RunningActionPhase::Serial,
}
}
@@ -527,8 +530,16 @@ impl BlocklistAIActionExecutor {
is_user_initiated: bool,
ctx: &mut ModelContext<Self>,
) -> TryExecuteResult {
log::info!(
"[tool-debug] try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
action.id,
std::mem::discriminant(&action.action),
is_user_initiated
);
// We should never actually execute actions in view-only mode.
if self.is_shared_session_viewer() {
log::info!("[tool-debug] try_to_execute_action: BLOCKED - shared session viewer mode");
return TryExecuteResult::NotExecuted {
reason: NotExecutedReason::WaitingOnSharer,
action: Box::new(action),
@@ -541,6 +552,11 @@ impl BlocklistAIActionExecutor {
};
let can_auto_execute = self.should_autoexecute(input, ctx);
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
log::info!(
"[tool-debug] try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
can_auto_execute,
is_agent_autonomous
);
// The agent cannot auto execute and either:
// - the agent is interactive, OR
@@ -549,6 +565,10 @@ impl BlocklistAIActionExecutor {
|| can_auto_execute
|| (is_agent_autonomous && action.action.is_request_command_output()));
if needs_confirmation {
log::info!(
"[tool-debug] try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
action.id
);
return TryExecuteResult::NotExecuted {
action: Box::new(action),
reason: NotExecutedReason::NeedsConfirmation,
@@ -580,6 +600,11 @@ impl BlocklistAIActionExecutor {
}
}
log::info!(
"[tool-debug] try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
action.id,
std::mem::discriminant(&action.action)
);
let action_clone = action.clone();
let execution = match &action.action {
AIAgentActionType::RequestCommandOutput { .. }
@@ -702,12 +727,26 @@ impl BlocklistAIActionExecutor {
};
let action_id = action_clone.id.clone();
match execution {
AnyActionExecution::NotReady => TryExecuteResult::NotExecuted {
reason: NotExecutedReason::NotReady,
action: Box::new(action_clone),
log::info!(
"[tool-debug] try_to_execute_action: execution result type={:?} for action_id={:?}",
match &execution {
AnyActionExecution::NotReady => "NotReady",
AnyActionExecution::InvalidAction => "InvalidAction",
AnyActionExecution::Async { .. } => "Async",
AnyActionExecution::Sync(_) => "Sync",
},
action_id
);
match execution {
AnyActionExecution::NotReady => {
log::info!("[tool-debug] try_to_execute_action: NOT READY - action_id={:?}", action_id);
TryExecuteResult::NotExecuted {
reason: NotExecutedReason::NotReady,
action: Box::new(action_clone),
}
}
AnyActionExecution::InvalidAction => {
log::error!("[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}", action_id);
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
TryExecuteResult::NotExecuted {
reason: NotExecutedReason::NotReady,
@@ -728,11 +767,18 @@ impl BlocklistAIActionExecutor {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
});
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
ctx.spawn(execute_future, move |me, result, ctx| {
let Some(running) = me.async_executing_actions.remove(&action_id) else {
log::warn!("[tool-debug] try_to_execute_action: async action completed but not found in executing map, action_id={:?}", action_id);
return;
};
let result = on_complete(result, ctx);
log::info!(
"[tool-debug] try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
action_id,
std::mem::discriminant(&result)
);
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
id: action_id,
@@ -84,6 +84,7 @@ impl CallMCPToolExecutor {
#[cfg(not(target_family = "wasm"))]
{
log::info!("[tool-debug] CallMCPToolExecutor::execute called");
let server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction {
action:
@@ -95,13 +96,21 @@ impl CallMCPToolExecutor {
..
} = input.action
else {
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!");
return ActionExecution::InvalidAction;
};
let name_owned = name.to_owned();
let name_clone = name_owned.clone();
log::info!(
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
name,
server_id,
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
);
let serde_json::Value::Object(mut arguments) = input.clone() else {
log::error!("[tool-debug] CallMCPToolExecutor: input is not an object!");
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
));
@@ -132,10 +141,12 @@ impl CallMCPToolExecutor {
};
let Some(reconnecting_peer) = templatable_peer else {
log::error!("[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND", name_owned);
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
log::info!("[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'", name_owned);
let name_owned_inner = name_owned.clone();
ActionExecution::new_async(
@@ -210,6 +221,11 @@ fn handle_call_tool_result(
tool_name: String,
ctx: &galaxyui::AppContext,
) -> AIAgentActionResultType {
log::info!(
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}",
tool_name,
res.is_ok()
);
let action_result = match res {
Ok(result) => {
// Even if the call was successful, the response could still be an error so we need to check.
@@ -111,6 +111,7 @@ impl FileGlobExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}", patterns, path);
// If the path is not provided, use the current working directory.
let path = path.clone().unwrap_or_else(|| ".".to_string());
@@ -252,6 +252,7 @@ impl GrepExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}", queries, path);
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let shell_type = self.active_session.as_ref(ctx).shell_type(ctx);
@@ -95,6 +95,10 @@ impl ReadFilesExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!(
"[tool-debug] ReadFilesExecutor::execute: {} files requested",
locations.len()
);
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
model.add_temporary_file_read_permissions(
@@ -151,9 +151,10 @@ impl RequestFileEditsExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}", id);
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!("Tried to execute a RequestFileEdits action without a diff view");
log::warn!("[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}", id);
return ActionExecution::NotReady;
};
@@ -62,6 +62,27 @@ impl SendMessageToAgentExecutor {
.and_then(|c| c.run_id())
.map(|s| s.to_string())
.unwrap_or_default();
// In local Bedrock mode, child agents run autonomously in their
// own conversation loop — there's no server API to relay messages.
// Return an error explaining the limitation so the model doesn't
// keep polling in a loop.
if sender_run_id.is_empty() {
log::info!(
"[send_message] No run_id for conversation {:?}, assuming local mode — skipping server API call",
conversation_id
);
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
SendMessageToAgentResult::Error(
"Child agents are running autonomously in local mode. \
You cannot send messages to them. They will complete their \
tasks independently. Continue with your own work or wait \
for the user to share the results."
.to_string(),
),
))
.into();
}
let log_addresses = addresses.clone();
let log_subject = subject.clone();
let log_sender_run_id = sender_run_id.clone();
@@ -212,6 +212,10 @@ impl ShellCommandExecutor {
input: ExecuteActionInput,
ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> {
log::info!(
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}",
std::mem::discriminant(&input.action.action)
);
let model = self.terminal_model.lock();
// Determine the action we want to take based on the input.
@@ -1,7 +1,7 @@
use futures::{future::BoxFuture, FutureExt};
use galaxyui::{Entity, ModelContext, SingletonEntity};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
StartAgentExecutionMode, StartAgentResult,
@@ -18,6 +18,8 @@ use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessA
enum StartAgentDecision {
/// The child conversation was created successfully.
Started { agent_id: String },
/// The child agent completed and here is its output (local/blocking mode).
Completed { agent_id: String, output: String },
/// An error occurred while starting the agent.
Error(String),
}
@@ -43,19 +45,22 @@ pub struct StartAgentRequest {
pub parent_run_id: Option<String>,
}
/// Tracks a single in-flight StartAgent action. At most one can be pending at
/// a time because StartAgent actions execute serially (RunningActionPhase::Serial).
/// Tracks a single in-flight StartAgent action.
struct PendingStartAgent {
parent_conversation_id: AIConversationId,
/// Set when `StartedNewConversation` fires for a conversation whose
/// `parent_conversation_id` matches.
child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentDecision>,
/// When true, the executor blocks until the child agent finishes and
/// returns its output as the tool result (local Bedrock mode).
wait_for_completion: bool,
}
pub struct StartAgentExecutor {
/// The currently pending StartAgent action, if any.
pending: Option<PendingStartAgent>,
/// All in-flight StartAgent actions. Multiple agents can be spawned
/// concurrently from the same parent.
pending: Vec<PendingStartAgent>,
}
impl StartAgentExecutor {
@@ -63,7 +68,7 @@ impl StartAgentExecutor {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
Self { pending: None }
Self { pending: Vec::new() }
}
fn handle_history_event(
@@ -76,44 +81,106 @@ impl StartAgentExecutor {
new_conversation_id,
..
} => {
let Some(pending) = self.pending.as_mut() else {
return;
};
if pending.child_conversation_id.is_some() {
if self.pending.is_empty() {
return;
}
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(new_conversation_id) else {
return;
};
if conversation.parent_conversation_id() == Some(pending.parent_conversation_id) {
let parent_id = conversation.parent_conversation_id();
// Find the first pending entry that matches this parent and hasn't been assigned a child yet.
if let Some(pending) = self.pending.iter_mut().find(|p| {
p.child_conversation_id.is_none()
&& parent_id == Some(p.parent_conversation_id)
}) {
pending.child_conversation_id = Some(*new_conversation_id);
}
}
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id, ..
} => {
let matches = self
.pending
.as_ref()
.is_some_and(|p| p.child_conversation_id.as_ref() == Some(conversation_id));
if !matches {
let Some(idx) = self.pending.iter().position(|p| {
p.child_conversation_id.as_ref() == Some(conversation_id)
}) else {
return;
};
// Don't remove yet if we're waiting for completion — we need
// the entry to stay so UpdatedConversationStatus can find it.
if self.pending[idx].wait_for_completion {
// Just log and continue — we'll resolve on Success status.
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id);
let agent_id = conversation
.and_then(|c| c.orchestration_agent_id())
.or_else(|| {
conversation.and_then(|c| {
c.server_conversation_token()
.map(|t| t.as_str().to_string())
})
})
.unwrap_or_else(|| conversation_id.to_string());
log::info!(
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
conversation_id,
agent_id
);
log::info!(
"[start_agent] Local mode: waiting for child {:?} to complete",
conversation_id
);
return;
}
let pending = self.pending.take().unwrap();
let agent_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.and_then(|c| c.orchestration_agent_id());
match agent_id {
Some(id) => {
let _ = pending.sender.try_send(StartAgentDecision::Started {
agent_id: id.clone(),
});
let pending = self.pending.remove(idx);
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id);
// orchestration_agent_id() uses run_id in v2 mode, which won't
// exist for locally-spawned Bedrock child agents. Fall back to
// the server conversation token (set by the stream Init event)
// or the conversation ID itself as the agent identifier.
let agent_id = conversation
.and_then(|c| c.orchestration_agent_id())
.or_else(|| {
conversation.and_then(|c| {
c.server_conversation_token()
.map(|t| t.as_str().to_string())
})
})
.unwrap_or_else(|| conversation_id.to_string());
log::info!(
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
conversation_id,
agent_id
);
// Only register with the orchestration streamer if the parent
// has a run_id (server-assigned). Local Bedrock child agents
// don't have server-side orchestration — attempting to open an
// SSE stream to a non-existent server freezes the app.
let parent_has_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&pending.parent_conversation_id)
.and_then(|c| c.run_id())
.is_some();
if pending.wait_for_completion {
// Local mode: don't resolve yet — wait for the child to
// finish (UpdatedConversationStatus → Success) so we can
// return its output as the tool result.
log::info!(
"[start_agent] Local mode: waiting for child {:?} to complete",
conversation_id
);
} else {
let _ = pending.sender.try_send(StartAgentDecision::Started {
agent_id: agent_id.clone(),
});
if parent_has_run_id {
if FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
streamer.register_watched_run_id(
pending.parent_conversation_id,
id,
agent_id,
ctx,
);
});
@@ -123,60 +190,70 @@ impl StartAgentExecutor {
});
}
}
None => {
log::error!(
"ConversationServerTokenAssigned fired but no agent identifier for \
{conversation_id:?}"
);
let _ = pending.sender.try_send(StartAgentDecision::Error(
"Server did not assign an agent identifier".to_string(),
));
if !FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_errored(
*conversation_id,
"missing_agent_id".to_string(),
"Server did not assign an agent identifier".to_string(),
ctx,
);
});
}
}
}
}
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} => {
let matches = self
.pending
.as_ref()
.is_some_and(|p| p.child_conversation_id.as_ref() == Some(conversation_id));
if !matches {
let Some(idx) = self.pending.iter().position(|p| {
p.child_conversation_id.as_ref() == Some(conversation_id)
}) else {
return;
}
};
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history.conversation(conversation_id) else {
return;
};
let error_msg = start_agent_error_message_for_status(
conversation.status(),
conversation.status_error_message(),
);
if let Some(error_msg) = error_msg {
let pending = self.pending.take().unwrap();
let _ = pending
.sender
.try_send(StartAgentDecision::Error(error_msg.clone()));
if !FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_errored(
*conversation_id,
"conversation_status".to_string(),
error_msg,
ctx,
);
match conversation.status() {
ConversationStatus::Success => {
if !self.pending[idx].wait_for_completion {
// Non-blocking mode — already resolved on token assignment.
return;
}
let pending = self.pending.remove(idx);
// Extract the child's text output from its last exchange.
let output = extract_child_output(conversation);
log::info!(
"[start_agent] Child agent {:?} completed with {} chars of output",
conversation_id,
output.len()
);
let agent_id = conversation
.orchestration_agent_id()
.or_else(|| {
conversation
.server_conversation_token()
.map(|t| t.as_str().to_string())
})
.unwrap_or_else(|| conversation_id.to_string());
let _ = pending.sender.try_send(StartAgentDecision::Completed {
agent_id,
output,
});
}
status => {
let error_msg = start_agent_error_message_for_status(
status,
conversation.status_error_message(),
);
if let Some(error_msg) = error_msg {
let pending = self.pending.remove(idx);
let _ = pending
.sender
.try_send(StartAgentDecision::Error(error_msg.clone()));
if !FeatureFlag::OrchestrationV2.is_enabled() {
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.emit_child_startup_errored(
*conversation_id,
"conversation_status".to_string(),
error_msg,
ctx,
);
});
}
}
}
}
}
BlocklistAIHistoryEvent::CreatedSubtask { .. }
@@ -356,11 +433,16 @@ impl StartAgentExecutor {
}
};
// In local mode (no parent_run_id), block until the child finishes
// so the parent model receives the child's output as the tool result.
let wait_for_completion = parent_run_id.is_none();
let (sender, receiver) = async_channel::bounded(1);
self.pending = Some(PendingStartAgent {
self.pending.push(PendingStartAgent {
parent_conversation_id,
child_conversation_id: None,
sender,
wait_for_completion,
});
ctx.emit(StartAgentExecutorEvent::CreateAgent(StartAgentRequest {
@@ -380,6 +462,15 @@ impl StartAgentExecutor {
version,
})
}
Ok(StartAgentDecision::Completed { agent_id, output }) => {
// Return the child's output as a "success" with the output
// embedded in the agent_id field. The Display impl on
// StartAgentResult will show this to the model.
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
agent_id: format!("{agent_id}\n\nAgent output:\n{output}"),
version,
})
}
Ok(StartAgentDecision::Error(error)) => {
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
}
@@ -399,6 +490,25 @@ impl StartAgentExecutor {
}
}
/// Extracts the text output from a child agent's conversation.
/// Collects text from all exchanges in the conversation.
fn extract_child_output(conversation: &AIConversation) -> String {
let mut output_parts = Vec::new();
for exchange in conversation.all_exchanges() {
if let Some(output) = exchange.output_status.output() {
let text = output.get().format_for_copy(None);
if !text.is_empty() {
output_parts.push(text);
}
}
}
if output_parts.is_empty() {
"Agent completed but produced no text output.".to_string()
} else {
output_parts.join("\n\n")
}
}
fn start_agent_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
+24
View File
@@ -2656,6 +2656,30 @@ impl BlocklistAIController {
"[bedrock-debug] AfterStreamFinished: NO actions to queue, was_passive={}, is_any_unfinished={}",
was_passive_request, is_any_exchange_unfinished
);
// If this is a child conversation (has a parent) and the
// stream ended with EndTurn and no actions, the child agent
// is done. Mark it as Success so the StartAgentExecutor
// can resolve and return the output to the parent.
let is_child = history_model
.as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.parent_conversation_id())
.is_some();
if is_child && !was_passive_request {
log::info!(
"[bedrock-debug] AfterStreamFinished: child conversation {:?} completed, setting status to Success",
conversation_id
);
history_model.update(ctx, |history_model, ctx| {
history_model.update_conversation_status(
self.terminal_view_id,
conversation_id,
crate::ai::agent::conversation::ConversationStatus::Success,
ctx,
);
});
}
}
// Cancelled streams will handle pending_response_stream updates synchronously.
+24 -3
View File
@@ -888,6 +888,10 @@ pub struct PaneGroup {
/// be revealed from the parent's status card.
child_agent_panes: HashMap<AIConversationId, PaneId>,
/// Child agent conversations that the user has explicitly closed.
/// Prevents them from being re-created on app restart.
dismissed_child_agents: HashSet<AIConversationId>,
/// Tab-level custom title set via the rename-tab flow.
custom_title: Option<String>,
}
@@ -3001,6 +3005,7 @@ impl PaneGroup {
is_right_panel_maximized: false,
pending_ambient_agent_conversation_restorations: HashMap::new(),
child_agent_panes: HashMap::new(),
dismissed_child_agents: HashSet::new(),
custom_title: None,
};
@@ -3052,14 +3057,18 @@ impl PaneGroup {
// Check in-memory children (live conversations).
for child in history_model.child_conversations_of(parent_id) {
let child_id = child.id();
if !self.child_agent_panes.contains_key(&child_id) {
if !self.child_agent_panes.contains_key(&child_id)
&& !self.dismissed_child_agents.contains(&child_id)
{
children_to_create.insert(child_id);
}
}
// Check the startup index for children not yet in memory.
for &child_id in history_model.child_conversation_ids_of(&parent_id) {
if !self.child_agent_panes.contains_key(&child_id) {
if !self.child_agent_panes.contains_key(&child_id)
&& !self.dismissed_child_agents.contains(&child_id)
{
children_to_create.insert(child_id);
}
}
@@ -4448,16 +4457,28 @@ impl PaneGroup {
return;
}
// If this pane is a child agent, re-hide it instead of closing it.
// If this pane is a child agent, remove it from tracking and
// discard it fully so it doesn't reappear on re-launch.
if self.is_child_agent_pane(pane_id) {
let dismissed_id = self
.child_agent_panes
.iter()
.find(|(_, p)| **p == pane_id)
.map(|(id, _)| *id);
if let Some(id) = dismissed_id {
self.dismissed_child_agents.insert(id);
}
self.child_agent_panes.retain(|_, &mut p| p != pane_id);
if !self.panes.is_pane_hidden(&pane_id) {
self.panes.hide_pane_for_child_agent(pane_id);
}
self.panes.remove_hidden_pane(pane_id);
self.focus_next_terminal_pane_and_activate_session(
pane_id,
PaneRemovalReason::Close,
ctx,
);
self.pane_contents.remove(&pane_id);
self.handle_pane_count_change(ctx);
ctx.emit(Event::TerminalViewStateChanged);
ctx.emit(Event::AppStateChanged);
+3 -3
View File
@@ -634,9 +634,9 @@ fn render_session_status_bar(appearance: &Appearance, app: &AppContext, conversa
let (cache_read, cache_write, cache_miss, cost_cents, context_usage, current_context) =
if let Some(conversation) = BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id) {
(
conversation.total_cache_read_tokens(),
conversation.total_cache_write_tokens(),
conversation.cache_miss_tokens(),
conversation.last_block_cache_read_tokens(),
conversation.last_block_cache_write_tokens(),
conversation.last_block_cache_miss_tokens(),
conversation.total_cost_cents(),
conversation.context_window_usage(),
conversation.current_context_tokens(),