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:
Hunter Bown
2026-05-02 09:56:33 -05:00
parent 42eea19066
commit 0c55c732a2
11 changed files with 34 additions and 2625 deletions
-7
View File
@@ -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 {
-1
View File
@@ -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;
-12
View File
@@ -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
+3 -24
View File
@@ -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)
+7 -255
View File
@@ -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);
+1 -31
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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() {
+7 -28
View File
@@ -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
+2 -3
View File
@@ -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"),