返回 DeepSeek-TUI-2026
subagent_routing.rs
根目录 / crates / tui / src / tui / subagent_routing.rs
1 //! Sub-agent and background-task routing helpers for the TUI loop.
2
3 use std::time::Instant;
4
5 use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary};
6 use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus};
7 use crate::tui::app::{App, AppMode, TaskPanelEntry};
8 use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
9 use crate::tui::pager::PagerView;
10 use crate::tui::widgets::agent_card::{
11 AgentLifecycle, DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout,
12 };
13
14 pub(super) fn running_agent_count(app: &App) -> usize {
15 let mut ids: std::collections::HashSet<&str> =
16 app.agent_progress.keys().map(String::as_str).collect();
17 for agent in app
18 .subagent_cache
19 .iter()
20 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
21 {
22 ids.insert(agent.agent_id.as_str());
23 }
24 ids.len()
25 }
26
27 pub(super) fn active_fanout_counts(app: &App) -> Option<(usize, usize)> {
28 // Read running count from the canonical slot states on the active
29 // FanoutCard, if one exists. Used by `rlm` and any future multi-child
30 // dispatch the parent agent makes via repeated `agent_spawn`.
31 if let Some(idx) = app.last_fanout_card_index
32 && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get(idx)
33 {
34 let running = card
35 .workers
36 .iter()
37 .filter(|slot| matches!(slot.status, AgentLifecycle::Running))
38 .count();
39 return Some((running, card.worker_count()));
40 }
41 None
42 }
43
44 pub(super) fn reconcile_subagent_activity_state(app: &mut App) {
45 let running_agents: Vec<(String, String)> = app
46 .subagent_cache
47 .iter()
48 .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
49 .map(|agent| {
50 (
51 agent.agent_id.clone(),
52 summarize_tool_output(&agent.assignment.objective),
53 )
54 })
55 .collect();
56
57 let running_ids: std::collections::HashSet<String> =
58 running_agents.iter().map(|(id, _)| id.clone()).collect();
59 app.agent_progress
60 .retain(|id, _| running_ids.contains(id.as_str()));
61 for (id, objective) in running_agents {
62 app.agent_progress.entry(id).or_insert(objective);
63 }
64
65 if running_ids.is_empty() {
66 app.agent_activity_started_at = None;
67 } else if app.agent_activity_started_at.is_none() {
68 app.agent_activity_started_at = Some(Instant::now());
69 }
70 }
71
72 fn subagent_status_rank(status: &SubAgentStatus) -> u8 {
73 match status {
74 SubAgentStatus::Running => 0,
75 SubAgentStatus::Interrupted(_) => 1,
76 SubAgentStatus::Failed(_) => 2,
77 SubAgentStatus::Completed => 3,
78 SubAgentStatus::Cancelled => 4,
79 }
80 }
81
82 pub(super) fn sort_subagents_in_place(agents: &mut [SubAgentResult]) {
83 agents.sort_by(|a, b| {
84 subagent_status_rank(&a.status)
85 .cmp(&subagent_status_rank(&b.status))
86 .then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str()))
87 .then_with(|| a.agent_id.cmp(&b.agent_id))
88 });
89 }
90
91 /// Route a `MailboxMessage` envelope to the matching in-transcript card,
92 /// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128).
93 pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &MailboxMessage) {
94 // Accumulate sub-agent token costs for the real-time footer counter (#166).
95 if let MailboxMessage::TokenUsage { model, usage, .. } = message {
96 if app.session.subagent_cost_event_seqs.insert(seq)
97 && let Some(cost) =
98 crate::pricing::calculate_turn_cost_estimate_from_usage(model, usage)
99 {
100 app.accrue_subagent_cost_estimate(cost);
101 }
102 return; // No card visual change needed; the footer handles display.
103 }
104
105 // Resolve (or allocate) the target cell for this envelope. ChildSpawned
106 // is special — it always belongs to the active fanout card if one
107 // exists; otherwise it seeds a new one.
108 let agent_id = message.agent_id().to_string();
109
110 if matches!(message, MailboxMessage::ChildSpawned { .. })
111 && let Some(idx) = app.last_fanout_card_index
112 && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx)
113 {
114 apply_to_fanout(card, message);
115 app.subagent_card_index.insert(agent_id, idx);
116 app.mark_history_updated();
117 return;
118 }
119
120 // Existing card for this agent_id? Mutate in place.
121 if let Some(&idx) = app.subagent_card_index.get(&agent_id) {
122 let updated = match app.history.get_mut(idx) {
123 Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => {
124 apply_to_delegate(card, message)
125 }
126 Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => {
127 apply_to_fanout(card, message)
128 }
129 _ => false,
130 };
131 if updated {
132 app.mark_history_updated();
133 }
134 return;
135 }
136
137 // No existing card — only `Started` reasonably opens one. Anything else
138 // for an unknown agent_id is dropped (likely arrived after the cell was
139 // cleared, e.g. session-resume edge cases).
140 let MailboxMessage::Started { agent_type, .. } = message else {
141 return;
142 };
143
144 let dispatch_kind = app.pending_subagent_dispatch.as_deref();
145 let is_fanout = matches!(dispatch_kind, Some("rlm"));
146
147 if is_fanout {
148 // Reuse the active fanout card for sibling spawns; otherwise create
149 // one anchored at this position so subsequent siblings join it.
150 if let Some(idx) = app.last_fanout_card_index
151 && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) =
152 app.history.get_mut(idx)
153 {
154 card.claim_pending_worker(&agent_id, AgentLifecycle::Running);
155 app.subagent_card_index.insert(agent_id, idx);
156 } else {
157 let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm").to_string());
158 card.upsert_worker(&agent_id, AgentLifecycle::Running);
159 app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
160 let idx = app.history.len().saturating_sub(1);
161 app.last_fanout_card_index = Some(idx);
162 app.subagent_card_index.insert(agent_id, idx);
163 }
164 } else {
165 let card = DelegateCard::new(agent_id.clone(), agent_type.clone());
166 app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card)));
167 let idx = app.history.len().saturating_sub(1);
168 app.subagent_card_index.insert(agent_id, idx);
169 // Single delegate consumes the pending dispatch label so a follow-on
170 // tool call doesn't accidentally inherit it.
171 app.pending_subagent_dispatch = None;
172 }
173
174 app.mark_history_updated();
175 }
176
177 pub(super) fn task_mode_label(mode: AppMode) -> &'static str {
178 mode.as_setting()
179 }
180
181 pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry {
182 TaskPanelEntry {
183 id: summary.id,
184 status: task_status_label(summary.status).to_string(),
185 prompt_summary: summary.prompt_summary,
186 duration_ms: summary.duration_ms,
187 }
188 }
189
190 fn task_status_label(status: TaskStatus) -> &'static str {
191 match status {
192 TaskStatus::Queued => "queued",
193 TaskStatus::Running => "running",
194 TaskStatus::Completed => "completed",
195 TaskStatus::Failed => "failed",
196 TaskStatus::Canceled => "canceled",
197 }
198 }
199
200 pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String {
201 if tasks.is_empty() {
202 return "No tasks found.".to_string();
203 }
204
205 let mut lines = vec![
206 format!("Tasks ({})", tasks.len()),
207 "----------------------------------------".to_string(),
208 ];
209 for task in tasks {
210 let duration = task
211 .duration_ms
212 .map(|ms| format!("{:.2}s", ms as f64 / 1000.0))
213 .unwrap_or_else(|| "-".to_string());
214 lines.push(format!(
215 "{} {:9} {} {}",
216 task.id,
217 task_status_label(task.status),
218 duration,
219 task.prompt_summary
220 ));
221 }
222 lines.push("Use /task show <id> for timeline details.".to_string());
223 lines.join("\n")
224 }
225
226 pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) {
227 let width = app
228 .viewport
229 .last_transcript_area
230 .map(|area| area.width)
231 .unwrap_or(100)
232 .saturating_sub(4);
233 app.view_stack.push(PagerView::from_text(
234 format!("Task {}", task.id),
235 &format_task_detail(task),
236 width.max(60),
237 ));
238 }
239
240 fn format_task_detail(task: &TaskRecord) -> String {
241 let mut lines = Vec::new();
242 lines.push(format!("Task: {}", task.id));
243 lines.push(format!("Status: {}", task_status_label(task.status)));
244 lines.push(format!("Mode: {}", task.mode));
245 lines.push(format!("Model: {}", task.model));
246 lines.push(format!(
247 "Workspace: {}",
248 crate::utils::display_path(&task.workspace)
249 ));
250 if let Some(thread_id) = task.thread_id.as_ref() {
251 lines.push(format!("Runtime Thread: {thread_id}"));
252 }
253 if let Some(turn_id) = task.turn_id.as_ref() {
254 lines.push(format!("Runtime Turn: {turn_id}"));
255 }
256 if task.runtime_event_count > 0 {
257 lines.push(format!("Runtime Events: {}", task.runtime_event_count));
258 }
259 lines.push(format!("Created: {}", task.created_at));
260 if let Some(started_at) = task.started_at {
261 lines.push(format!("Started: {}", started_at));
262 }
263 if let Some(ended_at) = task.ended_at {
264 lines.push(format!("Ended: {}", ended_at));
265 }
266 if let Some(duration) = task.duration_ms {
267 lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0));
268 }
269 lines.push(String::new());
270 lines.push("Prompt:".to_string());
271 lines.push(task.prompt.clone());
272
273 if let Some(summary) = task.result_summary.as_ref() {
274 lines.push(String::new());
275 lines.push("Result Summary:".to_string());
276 lines.push(summary.clone());
277 }
278 if let Some(path) = task.result_detail_path.as_ref() {
279 lines.push(format!("Result Artifact: {}", path.display()));
280 }
281 if let Some(error) = task.error.as_ref() {
282 lines.push(String::new());
283 lines.push(format!("Error: {error}"));
284 }
285
286 lines.push(String::new());
287 lines.push("Tool Calls:".to_string());
288 if task.tool_calls.is_empty() {
289 lines.push("- (none)".to_string());
290 } else {
291 for tool in &task.tool_calls {
292 let status = match tool.status {
293 crate::task_manager::TaskToolStatus::Running => "running",
294 crate::task_manager::TaskToolStatus::Success => "success",
295 crate::task_manager::TaskToolStatus::Failed => "failed",
296 crate::task_manager::TaskToolStatus::Canceled => "canceled",
297 };
298 let mut line = format!(
299 "- {} [{}] {}",
300 tool.name,
301 status,
302 tool.output_summary.as_deref().unwrap_or("(no summary)")
303 );
304 if let Some(duration) = tool.duration_ms {
305 line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0));
306 }
307 lines.push(line);
308 if let Some(path) = tool.detail_path.as_ref() {
309 lines.push(format!(" detail: {}", path.display()));
310 }
311 if let Some(path) = tool.patch_ref.as_ref() {
312 lines.push(format!(" patch: {}", path.display()));
313 }
314 }
315 }
316
317 lines.push(String::new());
318 lines.push("Timeline:".to_string());
319 if task.timeline.is_empty() {
320 lines.push("- (none)".to_string());
321 } else {
322 for entry in &task.timeline {
323 lines.push(format!(
324 "- [{}] {}: {}",
325 entry.timestamp, entry.kind, entry.summary
326 ));
327 if let Some(path) = entry.detail_path.as_ref() {
328 lines.push(format!(" detail: {}", path.display()));
329 }
330 }
331 }
332
333 lines.join("\n")
334 }
335
335 lines RUST