chore(tools): full SwarmOutcome cascade — delete swarm.rs + event variant + UI handlers (#357)
Completes the v0.8.5 cleanup #336 started: with the model-callable swarm surface gone, the supporting event/UI/state plumbing has no consumers. - Delete crates/tui/src/tools/swarm.rs (2215 lines, parked under #![allow(dead_code)] since #336) - Drop pub mod swarm from tools/mod.rs - Remove Event::SwarmProgress variant + handler in tui/ui.rs - Remove app.rs swarm fields: pending_swarm_task_count, swarm_jobs, last_swarm_id, swarm_card_index (and SwarmOutcome import + retain) - Remove subagent_routing.rs swarm helpers: seed_fanout_card_from_tool_call, sync_fanout_card_from_tool_result, sync_fanout_card_from_swarm_outcome, worker_slot_from_swarm_task, status_to_lifecycle, swarm_task_status_to_lifecycle - Simplify active_fanout_counts to read directly from the active FanoutCard - Simplify handle_subagent_mailbox is_fanout to only "rlm" dispatches - Strip dead "agent_swarm" / "spawn_agents_on_csv" string match arms in ui.rs (tool dispatch, task panel refresh, ListSubAgents trigger, active-cell skip), tool_card.rs (ToolFamily::Fanout), and tool_routing.rs (extract_fanout_prompts function deleted entirely) - Trim WorkerSlot to id/agent_id/status (label/model/nickname were only populated by worker_slot_from_swarm_task); remove unused with_agent ctor - Remove unused SubAgentManager::max_agents and ::available_slots methods (only swarm.rs called them) - Update widgets/agent_card.rs doc comments to point at rlm + future multi-child dispatch instead of agent_swarm FanoutCard decision: kept. It remains the visual primitive for rlm and for any future multi-child dispatch the parent agent makes via repeated agent_spawn calls. Net: 2698 lines removed, 90 added. Closes #357. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -200,13 +200,6 @@ pub enum Event {
|
||||
message: crate::tools::subagent::MailboxMessage,
|
||||
},
|
||||
|
||||
/// Authoritative swarm progress/outcome snapshot. Nonblocking
|
||||
/// `agent_swarm` returns before child agents finish, so the UI cannot
|
||||
/// rely on the original tool result as the final lifecycle state.
|
||||
SwarmProgress {
|
||||
outcome: crate::tools::swarm::SwarmOutcome,
|
||||
},
|
||||
|
||||
// === System Events ===
|
||||
/// An error occurred
|
||||
Error {
|
||||
|
||||
@@ -25,7 +25,6 @@ pub mod shell;
|
||||
mod shell_output;
|
||||
pub mod spec;
|
||||
pub mod subagent;
|
||||
pub mod swarm;
|
||||
pub mod tasks;
|
||||
pub mod test_runner;
|
||||
pub mod todo;
|
||||
|
||||
@@ -781,18 +781,6 @@ impl SubAgentManager {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Return the maximum number of allowed agents.
|
||||
#[must_use]
|
||||
pub fn max_agents(&self) -> usize {
|
||||
self.max_agents
|
||||
}
|
||||
|
||||
/// Return remaining capacity for new agents.
|
||||
#[must_use]
|
||||
pub fn available_slots(&self) -> usize {
|
||||
self.max_agents.saturating_sub(self.running_count())
|
||||
}
|
||||
|
||||
/// Spawn a new background sub-agent.
|
||||
pub fn spawn_background(
|
||||
&mut self,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@ use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
|
||||
use crate::tools::shell::new_shared_shell_manager;
|
||||
use crate::tools::spec::RuntimeToolServices;
|
||||
use crate::tools::subagent::SubAgentResult;
|
||||
use crate::tools::swarm::SwarmOutcome;
|
||||
use crate::tools::todo::{SharedTodoList, new_shared_todo_list};
|
||||
use crate::tui::active_cell::ActiveCell;
|
||||
use crate::tui::approval::ApprovalMode;
|
||||
@@ -528,31 +527,16 @@ pub struct App {
|
||||
/// than spawning duplicates.
|
||||
pub subagent_card_index: HashMap<String, usize>,
|
||||
/// History index of the most recent FanoutCard. Sibling sub-agents
|
||||
/// spawned by the same `agent_swarm` / `rlm` invocation route into
|
||||
/// this card; reset when a fresh fanout-family tool call starts.
|
||||
/// spawned by the same `rlm` invocation route into this card; reset
|
||||
/// when a fresh fanout-family tool call starts.
|
||||
pub last_fanout_card_index: Option<usize>,
|
||||
/// Number of tasks declared by a pending `agent_swarm` invocation that
|
||||
/// hasn't yet received its first SwarmProgress event. Used by the
|
||||
/// sidebar to show "dispatching N" before the FanoutCard exists (#236/#238).
|
||||
/// Cleared once sync_fanout_card_from_swarm_outcome creates the card.
|
||||
pub pending_swarm_task_count: Option<usize>,
|
||||
/// Canonical swarm/job snapshots by swarm id. Transcript cards, sidebar
|
||||
/// counts, and footer status read from this model instead of recomputing
|
||||
/// worker totals independently.
|
||||
pub swarm_jobs: HashMap<String, SwarmOutcome>,
|
||||
pub last_swarm_id: Option<String>,
|
||||
/// Swarm-id → history index for the FanoutCard that visualises that
|
||||
/// swarm. Bound on first sight of a SwarmProgress event, so background
|
||||
/// swarms keep updating their *own* card even when the user starts a
|
||||
/// second fanout in parallel. Pruned by `prune_history_state_after_clear`.
|
||||
pub swarm_card_index: HashMap<String, usize>,
|
||||
/// Highest cumulative session cost ever displayed. Used to keep the
|
||||
/// footer cost monotonic across reconciliation events: provisional
|
||||
/// estimates can be revised, but the visible total never decreases
|
||||
/// during a single session unless explicitly reset (#244).
|
||||
pub displayed_cost_high_water: f64,
|
||||
/// Most recently observed sub-agent dispatch tool name (set on
|
||||
/// `ToolCallStarted` for `agent_spawn` / `agent_swarm` / etc., cleared
|
||||
/// `ToolCallStarted` for `agent_spawn` / `rlm` / etc., cleared
|
||||
/// after the first `Started` mailbox envelope routes through it).
|
||||
pub pending_subagent_dispatch: Option<String>,
|
||||
/// Animation anchor for status-strip active sub-agent spinner.
|
||||
@@ -1016,10 +1000,6 @@ impl App {
|
||||
agent_progress: HashMap::new(),
|
||||
subagent_card_index: HashMap::new(),
|
||||
last_fanout_card_index: None,
|
||||
pending_swarm_task_count: None,
|
||||
swarm_jobs: HashMap::new(),
|
||||
last_swarm_id: None,
|
||||
swarm_card_index: HashMap::new(),
|
||||
displayed_cost_high_water: 0.0,
|
||||
pending_subagent_dispatch: None,
|
||||
agent_activity_started_at: None,
|
||||
@@ -1423,7 +1403,6 @@ impl App {
|
||||
.retain(|idx, _| *idx < new_len);
|
||||
self.rebuild_session_context_references();
|
||||
self.subagent_card_index.retain(|_, idx| *idx < new_len);
|
||||
self.swarm_card_index.retain(|_, idx| *idx < new_len);
|
||||
if self
|
||||
.last_fanout_card_index
|
||||
.is_some_and(|idx| idx >= new_len)
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary};
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus};
|
||||
use crate::tools::swarm::{SwarmOutcome, SwarmTaskStatus};
|
||||
use crate::tui::app::{App, AppMode, TaskPanelEntry};
|
||||
use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
|
||||
use crate::tui::pager::PagerView;
|
||||
use crate::tui::widgets::agent_card::{
|
||||
AgentLifecycle, DelegateCard, FanoutCard, WorkerSlot, apply_to_delegate, apply_to_fanout,
|
||||
AgentLifecycle, DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout,
|
||||
};
|
||||
|
||||
pub(super) fn running_agent_count(app: &App) -> usize {
|
||||
@@ -27,14 +25,9 @@ pub(super) fn running_agent_count(app: &App) -> usize {
|
||||
}
|
||||
|
||||
pub(super) fn active_fanout_counts(app: &App) -> Option<(usize, usize)> {
|
||||
// Canonical source: the in-progress SwarmOutcome from swarm_jobs.
|
||||
if let Some(swarm_id) = app.last_swarm_id.as_ref()
|
||||
&& let Some(outcome) = app.swarm_jobs.get(swarm_id)
|
||||
{
|
||||
return Some((outcome.counts.running, outcome.counts.total));
|
||||
}
|
||||
|
||||
// Card exists — read running count from the canonical slot states.
|
||||
// Read running count from the canonical slot states on the active
|
||||
// FanoutCard, if one exists. Used by `rlm` and any future multi-child
|
||||
// dispatch the parent agent makes via repeated `agent_spawn`.
|
||||
if let Some(idx) = app.last_fanout_card_index
|
||||
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get(idx)
|
||||
{
|
||||
@@ -45,240 +38,9 @@ pub(super) fn active_fanout_counts(app: &App) -> Option<(usize, usize)> {
|
||||
.count();
|
||||
return Some((running, card.worker_count()));
|
||||
}
|
||||
|
||||
// No card yet — swarm was just dispatched but no SwarmProgress has
|
||||
// arrived. Show the declared task count so the sidebar doesn't read zero.
|
||||
if let Some(total) = app.pending_swarm_task_count {
|
||||
return Some((0, total));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn seed_fanout_card_from_tool_call(
|
||||
app: &mut App,
|
||||
name: &str,
|
||||
input: &serde_json::Value,
|
||||
) -> bool {
|
||||
if name != "agent_swarm" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(tasks) = input.get("tasks").and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
if tasks.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Codex pattern: don't pre-seed a FanoutCard with all-Pending workers.
|
||||
// The card gets created by sync_fanout_card_from_swarm_outcome when the
|
||||
// first SwarmProgress carries real worker states. This eliminates the
|
||||
// "0 done · 0 running · 0 failed · N pending" vs sidebar "N running"
|
||||
// contradiction (#236/#238).
|
||||
//
|
||||
// Store the pending dispatch info so the transcript tool card (running
|
||||
// state) serves as the visual placeholder until workers start.
|
||||
app.pending_swarm_task_count = Some(tasks.len());
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn sync_fanout_card_from_tool_result(
|
||||
app: &mut App,
|
||||
name: &str,
|
||||
result: &Result<ToolResult, ToolError>,
|
||||
) -> bool {
|
||||
if name != "agent_swarm" {
|
||||
return false;
|
||||
}
|
||||
let Ok(tool_result) = result else {
|
||||
return false;
|
||||
};
|
||||
let Ok(payload) = serde_json::from_str::<serde_json::Value>(&tool_result.content) else {
|
||||
return false;
|
||||
};
|
||||
let Some(tasks) = payload
|
||||
.get("tasks")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.filter(|tasks| !tasks.is_empty())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Ok(outcome) = serde_json::from_value::<SwarmOutcome>(payload.clone()) {
|
||||
return sync_fanout_card_from_swarm_outcome(app, &outcome);
|
||||
}
|
||||
|
||||
let workers = tasks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, task)| {
|
||||
let task_id = task
|
||||
.get("task_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| idx.to_string());
|
||||
let agent_id = task
|
||||
.get("agent_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("task:{task_id}"));
|
||||
let status = task
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(status_to_lifecycle)
|
||||
.unwrap_or(AgentLifecycle::Pending);
|
||||
let mut slot =
|
||||
WorkerSlot::with_agent(format!("task:{task_id}"), Some(agent_id), status);
|
||||
slot.label = task
|
||||
.get("label")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
slot.model = task
|
||||
.get("model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
slot.nickname = task
|
||||
.get("nickname")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
slot
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let Some(idx) = app.last_fanout_card_index else {
|
||||
return false;
|
||||
};
|
||||
let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) else {
|
||||
return false;
|
||||
};
|
||||
card.workers = workers;
|
||||
app.mark_history_updated();
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn sync_fanout_card_from_swarm_outcome(app: &mut App, outcome: &SwarmOutcome) -> bool {
|
||||
app.swarm_jobs
|
||||
.insert(outcome.swarm_id.clone(), outcome.clone());
|
||||
app.last_swarm_id = Some(outcome.swarm_id.clone());
|
||||
|
||||
let workers = outcome
|
||||
.tasks
|
||||
.iter()
|
||||
.map(worker_slot_from_swarm_task)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if workers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bind this swarm to a card by id so concurrent fanouts each update
|
||||
// their own visualization. Order of preference:
|
||||
// 1) existing binding for this swarm_id (idempotent updates)
|
||||
// 2) the most recently seeded card (last_fanout_card_index) — which
|
||||
// typically corresponds to the fresh `agent_swarm` invocation
|
||||
// that just emitted this outcome's initial event
|
||||
// 3) allocate a fresh card and append it to history
|
||||
// Once chosen, the swarm_id↔card_index pair is cached so subsequent
|
||||
// SwarmProgress events for the *same* swarm always update the right
|
||||
// card even if `last_fanout_card_index` has since moved to another
|
||||
// overlapping fanout.
|
||||
let idx = if let Some(&bound) = app.swarm_card_index.get(&outcome.swarm_id)
|
||||
&& matches!(
|
||||
app.history.get(bound),
|
||||
Some(HistoryCell::SubAgent(SubAgentCell::Fanout(_)))
|
||||
) {
|
||||
bound
|
||||
} else if let Some(idx) = app.last_fanout_card_index
|
||||
&& matches!(
|
||||
app.history.get(idx),
|
||||
Some(HistoryCell::SubAgent(SubAgentCell::Fanout(_)))
|
||||
)
|
||||
&& !app.swarm_card_index.values().any(|bound| *bound == idx)
|
||||
{
|
||||
// The most recently-seeded card has no swarm bound to it yet; this
|
||||
// outcome's first SwarmProgress claims it. Any subsequent overlapping
|
||||
// fanout will allocate its own card below.
|
||||
idx
|
||||
} else {
|
||||
let card = FanoutCard::new("agent_swarm".to_string());
|
||||
app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
|
||||
let idx = app.history.len().saturating_sub(1);
|
||||
app.last_fanout_card_index = Some(idx);
|
||||
idx
|
||||
};
|
||||
app.swarm_card_index.insert(outcome.swarm_id.clone(), idx);
|
||||
|
||||
app.pending_swarm_task_count = None;
|
||||
|
||||
let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) else {
|
||||
return false;
|
||||
};
|
||||
card.kind = "agent_swarm".to_string();
|
||||
card.workers = workers;
|
||||
for task in &outcome.tasks {
|
||||
if let Some(agent_id) = task.agent_id.as_ref() {
|
||||
app.subagent_card_index.insert(agent_id.clone(), idx);
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.status.is_terminal() {
|
||||
app.pending_subagent_dispatch = None;
|
||||
}
|
||||
|
||||
app.mark_history_updated();
|
||||
true
|
||||
}
|
||||
|
||||
fn worker_slot_from_swarm_task(task: &crate::tools::swarm::SwarmTaskOutcome) -> WorkerSlot {
|
||||
let worker_id = if task.worker_id.trim().is_empty() {
|
||||
format!("task:{}", task.task_id)
|
||||
} else {
|
||||
task.worker_id.clone()
|
||||
};
|
||||
let agent_id = task
|
||||
.agent_id
|
||||
.clone()
|
||||
.or_else(|| Some(format!("task:{}", task.task_id)));
|
||||
let mut slot = WorkerSlot::with_agent(
|
||||
worker_id,
|
||||
agent_id,
|
||||
swarm_task_status_to_lifecycle(&task.status),
|
||||
);
|
||||
if !task.label.trim().is_empty() {
|
||||
slot.label = Some(task.label.clone());
|
||||
}
|
||||
if !task.model.trim().is_empty() {
|
||||
slot.model = Some(task.model.clone());
|
||||
}
|
||||
slot.nickname = task.nickname.clone();
|
||||
slot
|
||||
}
|
||||
|
||||
fn status_to_lifecycle(status: &str) -> AgentLifecycle {
|
||||
match status.trim().to_ascii_lowercase().as_str() {
|
||||
"completed" => AgentLifecycle::Completed,
|
||||
"running" => AgentLifecycle::Running,
|
||||
"failed" | "interrupted" => AgentLifecycle::Failed,
|
||||
"cancelled" | "canceled" | "skipped" => AgentLifecycle::Cancelled,
|
||||
_ => AgentLifecycle::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn swarm_task_status_to_lifecycle(status: &SwarmTaskStatus) -> AgentLifecycle {
|
||||
match status {
|
||||
SwarmTaskStatus::Completed => AgentLifecycle::Completed,
|
||||
SwarmTaskStatus::Running => AgentLifecycle::Running,
|
||||
SwarmTaskStatus::Failed | SwarmTaskStatus::Interrupted => AgentLifecycle::Failed,
|
||||
SwarmTaskStatus::Cancelled | SwarmTaskStatus::Skipped => AgentLifecycle::Cancelled,
|
||||
SwarmTaskStatus::Pending => AgentLifecycle::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_subagent_activity_state(app: &mut App) {
|
||||
let running_agents: Vec<(String, String)> = app
|
||||
.subagent_cache
|
||||
@@ -343,15 +105,8 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox
|
||||
// is special — it always belongs to the active fanout card if one
|
||||
// exists; otherwise it seeds a new one.
|
||||
let agent_id = message.agent_id().to_string();
|
||||
let belongs_to_known_swarm = app.swarm_jobs.values().any(|outcome| {
|
||||
!outcome.status.is_terminal()
|
||||
&& outcome
|
||||
.tasks
|
||||
.iter()
|
||||
.any(|task| task.agent_id.as_deref() == Some(agent_id.as_str()))
|
||||
});
|
||||
|
||||
if (matches!(message, MailboxMessage::ChildSpawned { .. }) || belongs_to_known_swarm)
|
||||
if matches!(message, MailboxMessage::ChildSpawned { .. })
|
||||
&& let Some(idx) = app.last_fanout_card_index
|
||||
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx)
|
||||
{
|
||||
@@ -386,10 +141,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox
|
||||
};
|
||||
|
||||
let dispatch_kind = app.pending_subagent_dispatch.as_deref();
|
||||
let is_fanout = matches!(
|
||||
dispatch_kind,
|
||||
Some("agent_swarm" | "spawn_agents_on_csv" | "rlm")
|
||||
) || belongs_to_known_swarm;
|
||||
let is_fanout = matches!(dispatch_kind, Some("rlm"));
|
||||
|
||||
if is_fanout {
|
||||
// Reuse the active fanout card for sibling spawns; otherwise create
|
||||
@@ -401,7 +153,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox
|
||||
card.claim_pending_worker(&agent_id, AgentLifecycle::Running);
|
||||
app.subagent_card_index.insert(agent_id, idx);
|
||||
} else {
|
||||
let mut card = FanoutCard::new(dispatch_kind.unwrap_or("swarm").to_string());
|
||||
let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm").to_string());
|
||||
card.upsert_worker(&agent_id, AgentLifecycle::Running);
|
||||
app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
|
||||
let idx = app.history.len().saturating_sub(1);
|
||||
|
||||
@@ -231,7 +231,6 @@ pub(super) fn handle_tool_call_started(
|
||||
}
|
||||
|
||||
let input_summary = summarize_tool_args(input);
|
||||
let prompts = extract_fanout_prompts(name, input);
|
||||
push_active_tool_cell(
|
||||
app,
|
||||
&id,
|
||||
@@ -242,40 +241,11 @@ pub(super) fn handle_tool_call_started(
|
||||
status: ToolStatus::Running,
|
||||
input_summary,
|
||||
output: None,
|
||||
prompts,
|
||||
prompts: None,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract per-child prompts from a fan-out tool's input. `agent_swarm`
|
||||
/// carries a structured `tasks` list up front, so the transcript can show
|
||||
/// one readable row per child instead of a collapsed JSON args blob.
|
||||
fn extract_fanout_prompts(name: &str, input: &serde_json::Value) -> Option<Vec<String>> {
|
||||
if name != "agent_swarm" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prompts = input
|
||||
.get("tasks")
|
||||
.and_then(serde_json::Value::as_array)?
|
||||
.iter()
|
||||
.filter_map(|task| {
|
||||
task.get("objective")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| task.get("prompt").and_then(serde_json::Value::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if prompts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(prompts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a tool cell as a new entry in `active_cell`, register the tool id,
|
||||
/// and write a stub detail record so the pager / Ctrl+O can find it.
|
||||
fn push_active_tool_cell(
|
||||
|
||||
+12
-47
@@ -68,9 +68,8 @@ use crate::tui::shell_job_routing::{
|
||||
};
|
||||
use crate::tui::subagent_routing::{
|
||||
active_fanout_counts, format_task_list, handle_subagent_mailbox, open_task_pager,
|
||||
reconcile_subagent_activity_state, running_agent_count, seed_fanout_card_from_tool_call,
|
||||
sort_subagents_in_place, sync_fanout_card_from_swarm_outcome,
|
||||
sync_fanout_card_from_tool_result, task_mode_label, task_summary_to_panel_entry,
|
||||
reconcile_subagent_activity_state, running_agent_count, sort_subagents_in_place,
|
||||
task_mode_label, task_summary_to_panel_entry,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::tui::tool_routing::exploring_label;
|
||||
@@ -574,27 +573,15 @@ async fn run_event_loop(
|
||||
// Note this dispatch so the next sub-agent `Started`
|
||||
// mailbox envelope routes into the right card kind
|
||||
// (delegate vs fanout).
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"agent_spawn"
|
||||
| "agent_swarm"
|
||||
| "spawn_agents_on_csv"
|
||||
| "rlm"
|
||||
| "delegate"
|
||||
) {
|
||||
if matches!(name.as_str(), "agent_spawn" | "rlm" | "delegate") {
|
||||
app.pending_subagent_dispatch = Some(name.clone());
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"agent_swarm" | "spawn_agents_on_csv" | "rlm"
|
||||
) {
|
||||
if name == "rlm" {
|
||||
// New fanout invocation — children should
|
||||
// group under a fresh card, not the
|
||||
// previous swarm's leftover.
|
||||
// previous fanout's leftover.
|
||||
app.last_fanout_card_index = None;
|
||||
app.last_swarm_id = None;
|
||||
}
|
||||
}
|
||||
seed_fanout_card_from_tool_call(app, &name, &input);
|
||||
handle_tool_call_started(app, &id, &name, &input);
|
||||
}
|
||||
EngineEvent::ToolCallComplete { id, name, result } => {
|
||||
@@ -619,7 +606,6 @@ async fn run_event_loop(
|
||||
}],
|
||||
});
|
||||
handle_tool_call_complete(app, &id, &name, &result);
|
||||
sync_fanout_card_from_tool_result(app, &name, &result);
|
||||
|
||||
// Immediately refresh the task panel sidebar when a
|
||||
// tool that changes task state completes, so the
|
||||
@@ -628,7 +614,7 @@ async fn run_event_loop(
|
||||
// poll.
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"agent_spawn" | "agent_swarm" | "agent_cancel" | "todo_write"
|
||||
"agent_spawn" | "agent_cancel" | "todo_write"
|
||||
) {
|
||||
let tasks = task_manager.list_tasks(Some(10)).await;
|
||||
app.task_panel =
|
||||
@@ -638,8 +624,6 @@ async fn run_event_loop(
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"agent_spawn"
|
||||
| "agent_swarm"
|
||||
| "spawn_agents_on_csv"
|
||||
| "agent_cancel"
|
||||
| "agent_wait"
|
||||
| "agent_result"
|
||||
@@ -975,21 +959,6 @@ async fn run_event_loop(
|
||||
handle_subagent_mailbox(app, seq, &message);
|
||||
transcript_batch_updated = true;
|
||||
}
|
||||
EngineEvent::SwarmProgress { outcome } => {
|
||||
if sync_fanout_card_from_swarm_outcome(app, &outcome) {
|
||||
transcript_batch_updated = true;
|
||||
}
|
||||
app.status_message = Some(format!(
|
||||
"Swarm {}: {} done, {} running, {} pending",
|
||||
outcome.swarm_id,
|
||||
outcome.counts.completed,
|
||||
outcome.counts.running,
|
||||
outcome.counts.pending
|
||||
));
|
||||
if outcome.status.is_terminal() {
|
||||
let _ = engine_handle.send(Op::ListSubAgents).await;
|
||||
}
|
||||
}
|
||||
EngineEvent::ApprovalRequired {
|
||||
id,
|
||||
tool_name,
|
||||
@@ -1731,9 +1700,9 @@ async fn run_event_loop(
|
||||
// waiting for the engine's TurnComplete echo to drain.
|
||||
// Idempotent with the TurnComplete handler that runs
|
||||
// when the engine actually echoes the cancel (#243).
|
||||
// Background `block:false` swarms continue running
|
||||
// — they are tracked in `swarm_jobs` independently and
|
||||
// their FanoutCard stays bound by `swarm_card_index`.
|
||||
// Background sub-agents continue running — they are
|
||||
// tracked via `subagent_cache` independently of the
|
||||
// foreground turn.
|
||||
app.finalize_active_cell_as_interrupted();
|
||||
app.finalize_streaming_assistant_as_interrupted();
|
||||
app.status_message = Some("Request cancelled".to_string());
|
||||
@@ -4904,15 +4873,11 @@ fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatu
|
||||
}
|
||||
ToolCell::Generic(generic) => {
|
||||
// Fanout-class dispatch tools represent themselves through the
|
||||
// FanoutCard + Agents sidebar, both of which derive from the
|
||||
// canonical `swarm_jobs` store. Counting them again here would
|
||||
// FanoutCard + Agents sidebar. Counting them again here would
|
||||
// produce the contradiction the user observed: footer "1 active"
|
||||
// while the card and sidebar already showed the swarm's own
|
||||
// while the card and sidebar already showed the dispatch's own
|
||||
// worker counts (#236, #238). Skip them entirely.
|
||||
if matches!(
|
||||
generic.name.as_str(),
|
||||
"agent_swarm" | "spawn_agents_on_csv" | "rlm" | "agent_spawn"
|
||||
) {
|
||||
if matches!(generic.name.as_str(), "rlm" | "agent_spawn") {
|
||||
return;
|
||||
}
|
||||
snapshot.record(format!("tool {}", generic.name), generic.status, None);
|
||||
|
||||
@@ -2354,8 +2354,8 @@ fn second_thinking_block_appends_new_entry_in_same_active_cell() {
|
||||
|
||||
// ---- per-child prompt wiring ----
|
||||
//
|
||||
// `extract_fanout_prompts` keeps fan-out tools readable by rendering one
|
||||
// row per child instead of a collapsed JSON args blob.
|
||||
// Generic tool cells default to `prompts: None`. Reserved for any future
|
||||
// fan-out tool that wants to surface per-child prompts.
|
||||
|
||||
#[test]
|
||||
fn non_fanout_tool_does_not_populate_prompts() {
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//!
|
||||
//! - [`DelegateCard`] — single `agent_spawn` invocation. Live tree of the
|
||||
//! last 3 actions plus a header with status / glyph / role.
|
||||
//! - [`FanoutCard`] — `agent_swarm` / `rlm` fanout. Dot-grid of worker
|
||||
//! slots (`●` filled, `○` pending) plus an aggregate counts line.
|
||||
//! - [`FanoutCard`] — `rlm` fanout (or any future multi-child dispatch).
|
||||
//! Dot-grid of worker slots (`●` filled, `○` pending) plus an aggregate
|
||||
//! counts line.
|
||||
//!
|
||||
//! Both cards are state machines updated by [`apply_to_delegate`] /
|
||||
//! [`apply_to_fanout`]. The sidebar (see `tui/sidebar.rs`) defers detail
|
||||
@@ -154,14 +155,11 @@ impl DelegateCard {
|
||||
/// One worker slot in a fanout group.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerSlot {
|
||||
/// Stable logical worker key. For swarms this stays tied to the task even
|
||||
/// after a concrete sub-agent id exists.
|
||||
/// Stable logical worker key. Stays tied to the worker slot even after a
|
||||
/// concrete sub-agent id exists.
|
||||
pub worker_id: String,
|
||||
/// Concrete agent id once spawned; placeholders use the worker id.
|
||||
pub agent_id: String,
|
||||
pub label: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub nickname: Option<String>,
|
||||
pub status: AgentLifecycle,
|
||||
}
|
||||
|
||||
@@ -172,32 +170,13 @@ impl WorkerSlot {
|
||||
Self {
|
||||
agent_id: worker_id.clone(),
|
||||
worker_id,
|
||||
label: None,
|
||||
model: None,
|
||||
nickname: None,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_agent(
|
||||
worker_id: impl Into<String>,
|
||||
agent_id: Option<String>,
|
||||
status: AgentLifecycle,
|
||||
) -> Self {
|
||||
let worker_id = worker_id.into();
|
||||
Self {
|
||||
agent_id: agent_id.unwrap_or_else(|| worker_id.clone()),
|
||||
worker_id,
|
||||
label: None,
|
||||
model: None,
|
||||
nickname: None,
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Card for `agent_swarm` / `rlm` fanout: dot-grid + aggregate counts.
|
||||
/// Card for `rlm` (or any multi-child dispatch) fanout: dot-grid +
|
||||
/// aggregate counts.
|
||||
///
|
||||
/// Slots are added as `ChildSpawned` envelopes arrive (or pre-allocated by
|
||||
/// the engine when the worker count is known up front); each slot
|
||||
|
||||
@@ -37,7 +37,7 @@ pub enum ToolFamily {
|
||||
Find,
|
||||
/// Single sub-agent dispatch. `◐ delegate`.
|
||||
Delegate,
|
||||
/// Multi-agent swarm (agent_swarm, csv, rlm). `⋮⋮ swarm`.
|
||||
/// Multi-agent fanout dispatch (rlm). `⋮⋮ fanout`.
|
||||
Fanout,
|
||||
/// Reasoning / chain-of-thought. `… think`. Reasoning has its own
|
||||
/// render path (`render_thinking` in `history.rs`); the family is
|
||||
@@ -78,7 +78,7 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily {
|
||||
"exec_shell" | "exec_shell_wait" | "exec_shell_interact" => ToolFamily::Run,
|
||||
"grep_files" | "file_search" | "web_search" | "fetch_url" => ToolFamily::Find,
|
||||
"agent_spawn" => ToolFamily::Delegate,
|
||||
"agent_swarm" | "spawn_agents_on_csv" | "rlm" => ToolFamily::Fanout,
|
||||
"rlm" => ToolFamily::Fanout,
|
||||
_ => ToolFamily::Generic,
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,6 @@ mod tests {
|
||||
assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run);
|
||||
assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find);
|
||||
assert_eq!(tool_family_for_name("agent_spawn"), ToolFamily::Delegate);
|
||||
assert_eq!(tool_family_for_name("agent_swarm"), ToolFamily::Fanout);
|
||||
assert_eq!(tool_family_for_name("rlm"), ToolFamily::Fanout);
|
||||
assert_eq!(
|
||||
tool_family_for_name("totally_new_tool"),
|
||||
|
||||
Reference in New Issue
Block a user