collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
//! Subagent system for parallel task execution.
//!
//! The main agent can spawn subagents for independent subtasks
//! (e.g., "search for X while editing Y"). Each subagent gets
//! its own conversation context and tool set.

use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

use crate::agent::context::ConversationContext;
use crate::agent::r#loop::AgentEvent;
use crate::api::provider::OpenAiCompatibleProvider;
use crate::config::Config;

/// A subagent task definition.
#[derive(Debug)]
pub struct SubagentTask {
    /// Unique identifier for this subagent.
    pub id: String,
    /// The task prompt for the subagent.
    pub prompt: String,
    /// Optional registered agent name. When set (Fork mode / explicit dispatch),
    /// the subagent uses that agent's system prompt and model.
    pub agent_name: Option<String>,
    /// Available agents passed for autonomous role selection (Hive/Flock modes).
    /// When non-empty and `agent_name` is None, the subagent decides its own role
    /// by reading the roster and task description.
    pub available_agents: Vec<crate::config::AgentDef>,
    /// Optional model override. When set, the subagent uses this model instead of inheriting the parent's.
    pub model_override: Option<String>,
    /// Optional working directory override (e.g., git worktree path).
    /// If None, inherits the caller's working_dir.
    pub working_dir_override: Option<String>,
    /// Optional channel to the coordinator's event stream.
    /// When set, the subagent forwards `SwarmWorkerApproaching` warnings in real-time
    /// so the TUI can display non-blocking progress alerts while the worker is running.
    pub outer_event_tx:
        Option<tokio::sync::mpsc::UnboundedSender<crate::agent::r#loop::AgentEvent>>,
    /// Optional external cancellation token.
    /// When set, the subagent uses this instead of creating its own — allowing
    /// external controllers (web UI, coordinator) to cancel a running worker.
    pub cancel_token: Option<CancellationToken>,
    /// Optional shared iteration budget.
    /// When set, the subagent's guard uses this for dynamic iteration-limit
    /// extension from external controllers (web UI "extend" action).
    pub iteration_budget: Option<crate::agent::guard::IterationBudget>,
    /// Optional instruction receiver for mid-stream control (redirect, pause, hint).
    /// When set, the agent loop drains this channel each iteration.
    pub instruction_rx: Option<
        tokio::sync::mpsc::UnboundedReceiver<crate::agent::swarm::knowledge::WorkerInstruction>,
    >,
}

/// Result from a completed subagent.
#[derive(Debug)]
pub struct SubagentResult {
    /// The subagent's ID.
    pub id: String,
    /// Whether the subagent completed successfully.
    pub success: bool,
    /// The final text response from the subagent.
    pub response: String,
    /// Files modified by the subagent.
    pub modified_files: Vec<String>,
    /// Number of tool calls made.
    pub tool_calls: u32,
    /// Cumulative input (prompt) tokens consumed.
    pub input_tokens: u64,
    /// Cumulative output (completion) tokens produced.
    pub output_tokens: u64,
    /// Set when the agent stopped due to an iteration or timeout limit mid-task.
    /// Contains a prompt instructing the next agent what remains to be done,
    /// derived from the agent's last response. When `Some`, the coordinator
    /// should re-queue the task rather than treating it as a failure.
    pub continuation_hint: Option<String>,
}

/// Pre-built resources shared across swarm workers to avoid redundant initialization.
///
/// All fields are behind `Arc` for cheap cloning across tokio spawn boundaries.
/// Workers that receive `Some(...)` reuse the shared instance; `None` means
/// the worker builds its own (backward-compatible default).
#[derive(Default, Clone)]
pub struct SharedResources {
    pub mcp_manager: Option<std::sync::Arc<crate::mcp::manager::McpManager>>,
    pub tool_index: Option<std::sync::Arc<crate::tools::tool_index::ToolIndex>>,
    pub skill_registry: Option<std::sync::Arc<crate::skills::SkillRegistry>>,
    pub shared_knowledge: Option<crate::agent::swarm::knowledge::SharedKnowledge>,
    /// Plugin hook runtime (shared from parent for SubagentStart/SubagentStop hooks).
    pub hook_runtime: Option<std::sync::Arc<crate::plugin::hooks::HookRuntime>>,
}

/// Spawn a subagent and run it to completion.
///
/// Returns a boxed future to break the recursive async type cycle
/// (run_with_mode → dispatch → subagent → spawn → run_with_mode).
///
/// When `mcp_manager` is provided, the subagent reuses the parent's MCP
/// connections instead of spawning new server processes (~30-45s faster).
pub fn spawn(
    task: SubagentTask,
    client: OpenAiCompatibleProvider,
    config: Config,
    system_prompt: String,
    working_dir: String,
    lsp_manager: crate::lsp::manager::LspManager,
    mcp_manager: Option<std::sync::Arc<crate::mcp::manager::McpManager>>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = SubagentResult> + Send>> {
    let shared = SharedResources {
        mcp_manager,
        ..Default::default()
    };
    Box::pin(spawn_inner(
        task,
        client,
        config,
        system_prompt,
        working_dir,
        lsp_manager,
        shared,
    ))
}

/// Spawn a subagent with shared resources (swarm mode).
///
/// The `SharedResources` bundle carries pre-built MCP connections, ToolIndex,
/// SkillRegistry, and SharedKnowledge so workers skip redundant initialization.
pub fn spawn_with_resources(
    task: SubagentTask,
    client: OpenAiCompatibleProvider,
    config: Config,
    system_prompt: String,
    working_dir: String,
    lsp_manager: crate::lsp::manager::LspManager,
    shared: SharedResources,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = SubagentResult> + Send>> {
    Box::pin(spawn_inner(
        task,
        client,
        config,
        system_prompt,
        working_dir,
        lsp_manager,
        shared,
    ))
}

async fn spawn_inner(
    mut task: SubagentTask,
    client: OpenAiCompatibleProvider,
    config: Config,
    system_prompt: String,
    working_dir: String,
    lsp_manager: crate::lsp::manager::LspManager,
    shared: SharedResources,
) -> SubagentResult {
    let mcp_manager = shared.mcp_manager;
    let shared_knowledge = shared.shared_knowledge;
    let shared_tool_index = shared.tool_index;
    let shared_skill_registry = shared.skill_registry;
    let hook_runtime = shared.hook_runtime;
    let instruction_rx = task.instruction_rx.take();

    // Attach iteration budget to config so the agent guard can be externally extended.
    let mut config = config;
    if let Some(budget) = task.iteration_budget.clone() {
        config.iteration_budget = Some(budget);
    }

    let resolved_agent_name = task.agent_name.as_deref().unwrap_or("agent").to_string();
    let agent_id_for_hooks = task.id.clone();
    let agent_name_for_hooks = resolved_agent_name.clone();
    let wd_for_hooks = working_dir.clone();

    // ── Plugin hooks: SubagentStart ──────────────────────────────────────
    if let Some(ref hr) = hook_runtime
        && hr.has_hooks(crate::plugin::hooks::HookEvent::SubagentStart)
    {
        let ctx = crate::plugin::hooks::HookContext::subagent_start(
            &agent_id_for_hooks,
            &agent_name_for_hooks,
            std::path::Path::new(&wd_for_hooks),
        );
        let results = hr
            .fire(crate::plugin::hooks::HookEvent::SubagentStart, &ctx)
            .await;
        for action in &results {
            if let crate::plugin::hooks::HookAction::Error(e) = action {
                tracing::warn!(error = %e, "Plugin SubagentStart hook error");
            }
        }
    }
    let agent_def = config
        .agents
        .iter()
        .find(|a| a.name.eq_ignore_ascii_case(&resolved_agent_name))
        .cloned();
    let effective_system_prompt =
        resolve_effective_system_prompt(&task, &config, agent_def.as_ref(), system_prompt);

    let context = ConversationContext::new(effective_system_prompt);
    let (event_tx, mut event_rx) = mpsc::unbounded_channel::<AgentEvent>();
    let cancel = task.cancel_token.clone().unwrap_or_default();

    let mut actual_client = client;
    apply_model_override(&mut actual_client, task.model_override.as_deref());

    let actual_client_for_reflect = actual_client.clone();
    let soul_enabled = super::soul::is_enabled(&config, agent_def.as_ref());
    let soul_home = config.collet_home.clone();
    let soul_model = config.model.clone();
    let soul_agent = resolved_agent_name.clone();
    let prompt = task.prompt.clone();
    let effective_working_dir = task.working_dir_override.clone().unwrap_or(working_dir);

    // Clone before move so post-completion ROLE claim can still access it.
    let knowledge_for_claim = shared_knowledge.clone();

    tokio::spawn(async move {
        let agent_params = crate::agent::r#loop::AgentParams {
            client: actual_client,
            config,
            context,
            user_msg: prompt,
            working_dir: effective_working_dir,
            event_tx,
            cancel,
            lsp_manager,
            trust_level: crate::trust::TrustLevel::Full,
            approval_gate: crate::agent::approval::ApprovalGate::yolo(),
            images: Vec::new(),
        };
        // Use an empty MCP manager when the parent didn't share one so the
        // subagent still goes through run_with_shared_mcp consistently.
        let mcp = mcp_manager
            .unwrap_or_else(|| std::sync::Arc::new(crate::mcp::manager::McpManager::empty()));
        crate::agent::r#loop::run_with_shared_mcp(
            agent_params,
            crate::agent::r#loop::SwarmParams {
                mcp_manager: mcp,
                shared_knowledge,
                shared_tool_index,
                shared_skill_registry,
                instruction_rx,
            },
        )
        .await;
    });

    // Collect results
    let mut response = String::new();
    let mut modified_files = Vec::new();
    let mut tool_calls = 0u32;
    let mut success = true;
    let mut input_tokens = 0u64;
    let mut output_tokens = 0u64;
    let mut continuation_hint: Option<String> = None;

    while let Some(event) = event_rx.recv().await {
        match event {
            AgentEvent::Token(token) => {
                response.push_str(&token);
                // Forward per-agent token to coordinator for graph visualization
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmAgentToken {
                        agent_id: task.id.clone(),
                        text: token,
                    });
                }
            }
            AgentEvent::Response(text) => {
                if response.is_empty() {
                    response = text.clone();
                }
                // Forward per-agent response to coordinator for graph visualization
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmAgentResponse {
                        agent_id: task.id.clone(),
                        text,
                    });
                }
            }
            AgentEvent::ToolCall {
                ref name, ref args, ..
            } => {
                tool_calls += 1;
                // Forward per-agent tool call to coordinator for graph visualization
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmAgentToolCall {
                        agent_id: task.id.clone(),
                        name: name.clone(),
                        args: args.clone(),
                    });
                }
            }
            AgentEvent::ToolResult {
                ref name,
                ref result,
                success: tool_success,
                ..
            } => {
                // Forward per-agent tool result to coordinator for graph visualization
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmAgentToolResult {
                        agent_id: task.id.clone(),
                        name: name.clone(),
                        result: truncate_result(result),
                        success: tool_success,
                    });
                }
                // Individual tool failures (test failures, non-zero exit codes, etc.)
                // are normal during investigation — do not mark the agent as failed.
            }
            AgentEvent::FileModified { path } => {
                modified_files.push(path);
            }
            AgentEvent::Status {
                prompt_tokens,
                completion_tokens,
                iteration,
                ..
            } => {
                input_tokens += prompt_tokens as u64;
                output_tokens += completion_tokens as u64;
                // Forward per-agent progress to coordinator for live TUI display.
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmAgentProgress {
                        agent_id: task.id.clone(),
                        agent_name: task.agent_name.clone().unwrap_or_else(|| task.id.clone()),
                        iteration,
                        status: format!(
                            "iter {iteration} ({prompt_tokens}t in, {completion_tokens}t out)"
                        ),
                    });
                }
            }
            AgentEvent::Error(_) => {
                success = false;
            }
            // Forward approaching-limit warnings to the coordinator's event stream
            // so the TUI can display a non-blocking alert while the worker is still running.
            AgentEvent::SwarmWorkerApproaching { remaining, .. } => {
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmWorkerApproaching {
                        agent_id: task.id.clone(),
                        task_preview: crate::util::truncate_bytes(&task.prompt, 80).to_string(),
                        remaining,
                    });
                }
            }
            // Forward pause/resume status to coordinator → TUI (fill in agent_id).
            AgentEvent::SwarmWorkerPaused { .. } => {
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmWorkerPaused {
                        agent_id: task.id.clone(),
                    });
                }
            }
            AgentEvent::SwarmWorkerResumed { .. } => {
                if let Some(ref tx) = task.outer_event_tx {
                    let _ = tx.send(AgentEvent::SwarmWorkerResumed {
                        agent_id: task.id.clone(),
                    });
                }
            }
            AgentEvent::Done {
                context: done_ctx,
                stop_reason,
                ..
            } => {
                // If the agent hit an iteration/timeout limit, build a continuation hint
                // so the coordinator can re-queue rather than treating this as a failure.
                if stop_reason
                    .as_ref()
                    .map(|r| r.is_continuable())
                    .unwrap_or(false)
                {
                    continuation_hint = Some(build_continuation_hint(&task.prompt, &response));
                    // Partial completion is still a success — don't mark as failed.
                }
                // Soul reflection for the domain agent this subagent inherited.
                // In Hive/Flock mode (available_agents set), the agent declares
                // its role via "ROLE: <name>" in the response. Use that claimed
                // role for the soul path; fall back to soul_agent otherwise.
                if soul_enabled {
                    let effective_soul_agent = if !task.available_agents.is_empty() {
                        response
                            .lines()
                            .find_map(|line| {
                                let l = line.trim();
                                l.to_uppercase()
                                    .starts_with("ROLE:")
                                    .then(|| l[5..].trim().to_lowercase())
                                    .filter(|r| !r.is_empty())
                            })
                            .unwrap_or_else(|| soul_agent.clone())
                    } else {
                        soul_agent.clone()
                    };
                    let messages = done_ctx.messages().to_vec();
                    let home = soul_home.clone();
                    let model = soul_model.clone();
                    let reflect_client = actual_client_for_reflect.clone();
                    tokio::spawn(async move {
                        if let Err(e) = super::soul::reflect_simple(
                            &reflect_client,
                            &model,
                            &home,
                            &effective_soul_agent,
                            &messages,
                            None,
                        )
                        .await
                        {
                            tracing::warn!(agent = %effective_soul_agent, "Subagent soul reflection failed: {e}");
                        }
                    });
                }
                break;
            }
            _ => {}
        }
    }

    if !task.available_agents.is_empty()
        && let Some(ref kb) = knowledge_for_claim
    {
        claim_role_on_blackboard(kb, &task.id, &response).await;
    }

    // ── Plugin hooks: SubagentStop ───────────────────────────────────────
    if let Some(ref hr) = hook_runtime
        && hr.has_hooks(crate::plugin::hooks::HookEvent::SubagentStop)
    {
        let ctx = crate::plugin::hooks::HookContext::subagent_stop(
            &agent_id_for_hooks,
            &agent_name_for_hooks,
            success,
            std::path::Path::new(&wd_for_hooks),
        );
        let results = hr
            .fire(crate::plugin::hooks::HookEvent::SubagentStop, &ctx)
            .await;
        for action in &results {
            if let crate::plugin::hooks::HookAction::Error(e) = action {
                tracing::warn!(error = %e, "Plugin SubagentStop hook error");
            }
        }
    }

    SubagentResult {
        id: task.id,
        success,
        response,
        modified_files,
        tool_calls,
        input_tokens,
        output_tokens,
        continuation_hint,
    }
}

/// Resolve the effective system prompt for a subagent.
///
/// 1. Fork / explicit (`agent_name` set): use the named agent's system prompt.
/// 2. Hive / Flock (`available_agents` non-empty): prepend a role-selection
///    preamble so the agent autonomously picks its role for this task.
/// 3. Fallback: caller-supplied `system_prompt` unchanged.
///
/// After resolution, Soul.md is appended when soul is enabled for the agent.
fn resolve_effective_system_prompt(
    task: &SubagentTask,
    config: &Config,
    agent_def: Option<&crate::config::AgentDef>,
    system_prompt: String,
) -> String {
    let base = if let Some(name) = task.agent_name.as_deref() {
        config
            .agents
            .iter()
            .find(|a| a.name.eq_ignore_ascii_case(name))
            .filter(|a| !a.system_prompt.is_empty())
            .map(|a| a.system_prompt.clone())
            .unwrap_or(system_prompt)
    } else if !task.available_agents.is_empty() {
        let roster = task
            .available_agents
            .iter()
            .filter(|a| !a.system_prompt.is_empty())
            .map(|a| {
                let desc = a.description.as_deref().unwrap_or("general-purpose agent");
                format!("- {}: {}", a.name, desc)
            })
            .collect::<Vec<_>>()
            .join("\n");
        format!(
            "## Agent Role Selection\n\
             You are one agent in a collaborative swarm. Based on your assigned task, \
             select the most appropriate role from the available agents below and fully \
             adopt that agent's behavior, expertise, and constraints for this task.\n\n\
             Available agents:\n{roster}\n\n\
             **REQUIRED**: Begin your very first response with exactly:\n\
             `ROLE: <agent_name>`\n\
             where <agent_name> is one of the names listed above. Then proceed with your work.\n\
             Example: `ROLE: security`\n\n\
             ---\n\n\
             {system_prompt}"
        )
    } else {
        system_prompt
    };

    let resolved_agent_name = task.agent_name.as_deref().unwrap_or("agent");
    if super::soul::is_enabled(config, agent_def)
        && let Some(soul) = super::soul::load(&config.collet_home, resolved_agent_name)
    {
        format!(
            "{base}\n\n\
             ## Soul (Your Persistent Memory & Personality)\n\n\
             The following is YOUR soul — your evolving identity, thoughts, and growth.\n\
             Let it influence your tone, opinions, and approach naturally.\n\n{soul}"
        )
    } else {
        base
    }
}

/// Apply a `provider/model` override to the client.
///
/// `spec` accepts either `provider/model` (full provider switch) or a bare
/// `model` name (model swap on the existing provider).
fn apply_model_override(client: &mut OpenAiCompatibleProvider, spec: Option<&str>) {
    let Some(spec) = spec else {
        return;
    };
    let Some(slash) = spec.find('/') else {
        client.model = spec.to_string();
        return;
    };
    let provider_name = &spec[..slash];
    let model = &spec[slash + 1..];
    if let Some((entry, api_key)) = crate::config::resolve_provider(provider_name) {
        if !entry.base_url.is_empty() {
            let profile = crate::api::model_profile::profile_for(model);
            client.switch_provider(
                entry.base_url,
                api_key,
                model.to_string(),
                profile.max_output_tokens,
            );
        } else {
            client.model = model.to_string();
        }
    } else {
        client.model = model.to_string();
    }
}

/// Parse the agent's `ROLE: <name>` declaration from the response and post a
/// claim to the blackboard so subsequent Hive/Flock agents exclude this role.
async fn claim_role_on_blackboard(
    kb: &crate::agent::swarm::knowledge::SharedKnowledge,
    task_id: &str,
    response: &str,
) {
    let claimed_role = response.lines().find_map(|line| {
        let l = line.trim();
        l.to_uppercase()
            .starts_with("ROLE:")
            .then(|| l[5..].trim().to_lowercase())
            .filter(|r| !r.is_empty())
    });
    if let Some(role) = claimed_role {
        tracing::info!(%task_id, %role, "Hive agent claimed role");
        kb.post_to_blackboard(
            &format!("role_claim:{role}"),
            &role,
            task_id,
            crate::agent::swarm::knowledge::BlackboardKind::Claim,
        )
        .await;
    } else {
        tracing::warn!(%task_id, "Hive agent did not declare ROLE: in response");
    }
}

/// Truncate a tool result string for SSE forwarding (avoid huge payloads).
fn truncate_result(s: &str) -> String {
    const MAX_LEN: usize = 2000;
    if s.len() <= MAX_LEN {
        s.to_string()
    } else {
        let truncated: String = s.chars().take(MAX_LEN).collect();
        format!("{truncated}\n... (truncated)")
    }
}

/// Build a continuation hint prompt from the original task and the agent's last response.
///
/// The hint is injected as a preamble in the re-queued task so the next agent
/// knows what was already done and what still needs to be completed.
fn build_continuation_hint(original_prompt: &str, last_response: &str) -> String {
    // Use the tail of the last response (last ~800 chars) as "progress so far".
    let progress = if last_response.len() > 800 {
        let start = last_response
            .char_indices()
            .rev()
            .nth(800)
            .map(|(i, _)| i)
            .unwrap_or(0);
        &last_response[start..]
    } else {
        last_response
    };

    format!(
        "## Continuation Task\n\
         The previous agent was stopped mid-task due to iteration limits.\n\
         It made partial progress — continue from where it left off.\n\n\
         ### Original task\n\
         {original_prompt}\n\n\
         ### Progress so far (last agent output)\n\
         {progress}\n\n\
         ### Your job\n\
         Review the progress above, identify what remains unfinished, and complete it.\n\
         Do not redo work that was already completed successfully."
    )
}

/// Spawn multiple subagents in parallel and collect all results.
///
/// Concurrency is bounded at two levels:
///   1. Global: `config.collaboration.max_agents`
///   2. Per-model: `[[models]] concurrency_limit` or built-in defaults
///
/// When a model is at capacity, the limiter tries fallback candidates
/// (agent `providers` chain → capability match → CLI providers → queue wait).
pub async fn spawn_parallel(
    tasks: Vec<SubagentTask>,
    client: OpenAiCompatibleProvider,
    config: Config,
    system_prompt: String,
    working_dir: String,
    lsp_manager: crate::lsp::manager::LspManager,
    mcp_manager: Option<std::sync::Arc<crate::mcp::manager::McpManager>>,
) -> Vec<SubagentResult> {
    use crate::agent::rate_limit::{
        AcquireResult, ModelKey, ModelRateLimiter, parse_providers_chain,
    };
    use std::sync::Arc;

    let limiter = Arc::new(ModelRateLimiter::new(&config));
    let mut handles = Vec::new();

    for task in tasks {
        let client = client.clone();
        let config = config.clone();
        let sp = system_prompt.clone();
        let wd = working_dir.clone();
        let lsp = lsp_manager.clone();
        let mcp = mcp_manager.clone();
        let limiter = limiter.clone();

        handles.push(tokio::spawn(async move {
            // Find the agent definition — prefer lookup by name, fall back to model match.
            let agent_def = task
                .agent_name
                .as_deref()
                .and_then(|name| {
                    config
                        .agents
                        .iter()
                        .find(|a| a.name.eq_ignore_ascii_case(name))
                })
                .or_else(|| {
                    let m = task.model_override.as_deref().unwrap_or(&config.model);
                    config.agents.iter().find(|a| a.model == m)
                });

            // Determine the primary model key for this task.
            // Priority: agent's own model > worker_model override > config default.
            // worker_model acts as a fallback for agents without an explicit model.
            let model = agent_def
                .map(|a| a.model.as_str())
                .filter(|m| *m != "default")
                .or(task.model_override.as_deref())
                .unwrap_or(&config.model);

            // Build primary key: use agent's provider if available.
            let primary_provider = agent_def
                .and_then(|a| a.provider.as_deref())
                .unwrap_or("default");
            let primary = ModelKey::new(primary_provider, model);

            // Parse fallback chain from agent's `providers` field.
            let fallback_chain: Vec<ModelKey> = agent_def
                .map(|a| parse_providers_chain(&a.providers.join(",")))
                .unwrap_or_default();

            // Acquire permit with fallback.
            let result = limiter
                .acquire_with_fallback(&primary, &fallback_chain, &config)
                .await;

            let (effective_key, _permit) = match result {
                AcquireResult::Acquired { key, permit } => (key, permit),
                AcquireResult::QueueWait { key } => {
                    // Block until primary model has capacity.
                    let permit = limiter.acquire_wait(&key).await;
                    (key, permit)
                }
            };

            // If fallback selected a different model, override the client.
            let mut actual_client = client;
            if effective_key.model != model || effective_key.provider != primary_provider {
                tracing::info!(
                    task_id = %task.id,
                    original = %primary.as_str(),
                    actual = %effective_key.as_str(),
                    "Subagent using fallback model"
                );
                // Try to resolve the fallback provider for full context switch.
                if let Some((entry, api_key)) =
                    crate::config::resolve_provider(&effective_key.provider)
                {
                    if entry.is_cli() {
                        // CLI provider — handled by the agent loop's CLI fast-path.
                        // We just set the model; cli fields are on Config.
                        actual_client.model = effective_key.model.clone();
                    } else {
                        let profile = crate::api::model_profile::profile_for(&effective_key.model);
                        actual_client.switch_provider(
                            entry.base_url.clone(),
                            api_key,
                            effective_key.model.clone(),
                            profile.max_output_tokens,
                        );
                    }
                } else {
                    actual_client.model = effective_key.model.clone();
                }
            }

            let mut task = task;
            task.model_override = Some(effective_key.model.clone());
            spawn(task, actual_client, config, sp, wd, lsp, mcp).await
        }));
    }

    let mut results = Vec::new();
    for handle in handles {
        match handle.await {
            Ok(result) => results.push(result),
            Err(e) => {
                tracing::error!("Subagent task panicked: {e}");
            }
        }
    }

    results
}