Skip to main content

sparrow/orchestrator/
mod.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::sync::Arc;
4use tokio::sync::{Mutex, mpsc};
5
6use crate::config::Config;
7use crate::engine::{Identity, Workspace};
8use crate::event::{AgentStatus, Block, Event, OutcomeSummary, RiskLevel, RunId, TokenUsage};
9use crate::memory::Memory;
10use crate::provider::{
11    Brain, BrainRequest, BrainStream, ContentBlock, ModelCaps, Msg, PromptCacheConfig, ToolSpec,
12};
13use crate::router::{BudgetState, Router, TaskTier};
14use crate::sandbox::LocalSandbox;
15use crate::tools::edit::{Edit, MultiEdit};
16use crate::tools::exec::Exec;
17use crate::tools::fs::{FsList, FsRead, FsWrite};
18use crate::tools::git::Git;
19use crate::tools::search_and_web::Search;
20use crate::tools::{ToolCtx, ToolRegistry};
21
22fn prompt_cache_key(scope: &str, workspace_root: &std::path::Path, tools: &[ToolSpec]) -> String {
23    use std::hash::{Hash, Hasher};
24
25    let mut hasher = std::collections::hash_map::DefaultHasher::new();
26    scope.hash(&mut hasher);
27    workspace_root.display().to_string().hash(&mut hasher);
28    for tool in tools {
29        tool.name.hash(&mut hasher);
30        tool.description.hash(&mut hasher);
31        tool.input_schema.to_string().hash(&mut hasher);
32    }
33    format!("sparrow-{}-{:016x}", scope, hasher.finish())
34}
35
36// ─── Fallback brain ──────────────────────────────────────────────────────────
37// Wraps an ordered chain of brains and tries each on `complete()` until one
38// succeeds. Lets each swarm role (planner/coder/verifier) survive a model that
39// 404s or rate-limits — discovery sometimes lists models that aren't callable.
40
41struct FallbackBrain {
42    id: String,
43    caps: ModelCaps,
44    chain: Vec<Arc<dyn Brain>>,
45}
46
47impl FallbackBrain {
48    fn new(chain: Vec<Arc<dyn Brain>>) -> Self {
49        let id = chain
50            .first()
51            .map(|b| b.id().to_string())
52            .unwrap_or_else(|| "none".into());
53        let caps = chain.first().map(|b| b.caps()).unwrap_or_default();
54        Self { id, caps, chain }
55    }
56}
57
58#[async_trait::async_trait]
59impl Brain for FallbackBrain {
60    fn id(&self) -> &str {
61        &self.id
62    }
63    fn caps(&self) -> ModelCaps {
64        self.caps.clone()
65    }
66    async fn complete(&self, req: BrainRequest) -> anyhow::Result<BrainStream> {
67        let mut last_err: Option<anyhow::Error> = None;
68        for brain in &self.chain {
69            match brain.complete(req.clone()).await {
70                Ok(stream) => return Ok(stream),
71                Err(e) => {
72                    tracing::warn!("swarm brain {} failed, trying next: {}", brain.id(), e);
73                    last_err = Some(e);
74                }
75            }
76        }
77        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no brains in fallback chain")))
78    }
79}
80
81// ─── Swarm types ────────────────────────────────────────────────────────────────
82
83#[derive(Debug, Clone)]
84pub struct SwarmPlan {
85    pub task: String,
86    pub workspace: PathBuf,
87    pub max_reworks: u32,
88}
89
90impl Default for SwarmPlan {
91    fn default() -> Self {
92        Self {
93            task: String::new(),
94            workspace: PathBuf::from("."),
95            max_reworks: 3,
96        }
97    }
98}
99
100#[derive(Debug, Clone)]
101pub struct SwarmOutcome {
102    pub status: String,
103    pub plan: Option<String>,
104    pub diffs: Vec<crate::event::FileDiff>,
105    pub passes: u32,
106    pub reworks: u32,
107    pub cost_usd: f64,
108}
109
110#[derive(Debug, Clone)]
111pub enum Verdict {
112    Pass,
113    Rework { findings: Vec<String> },
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SwarmPhase {
118    Planning,
119    Coding,
120    Verifying,
121    Reworking,
122    Done,
123}
124
125impl std::fmt::Display for SwarmPhase {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        match self {
128            SwarmPhase::Planning => write!(f, "planning"),
129            SwarmPhase::Coding => write!(f, "coding"),
130            SwarmPhase::Verifying => write!(f, "verifying"),
131            SwarmPhase::Reworking => write!(f, "reworking"),
132            SwarmPhase::Done => write!(f, "done"),
133        }
134    }
135}
136
137// ─── Anti-collision: file-level locks ───────────────────────────────────────────
138
139pub struct FileLocks {
140    locked: Mutex<HashSet<String>>,
141}
142
143impl FileLocks {
144    pub fn new() -> Self {
145        Self {
146            locked: Mutex::new(HashSet::new()),
147        }
148    }
149
150    pub async fn try_lock(&self, files: &[String]) -> Result<FileLockGuard, Vec<String>> {
151        let mut locked = self.locked.lock().await;
152        let mut conflicts = Vec::new();
153        for f in files {
154            if locked.contains(f) {
155                conflicts.push(f.clone());
156            }
157        }
158        if !conflicts.is_empty() {
159            return Err(conflicts);
160        }
161        for f in files {
162            locked.insert(f.clone());
163        }
164        Ok(FileLockGuard {
165            _files: files.to_vec(),
166        })
167    }
168
169    pub async fn release(&self, files: &[String]) {
170        let mut locked = self.locked.lock().await;
171        for f in files {
172            locked.remove(f);
173        }
174    }
175}
176
177pub struct FileLockGuard {
178    _files: Vec<String>,
179}
180
181fn swarm_tool_registry(workspace: &Workspace, write_enabled: bool) -> Arc<ToolRegistry> {
182    let mut registry = ToolRegistry::new();
183    registry.register(Arc::new(FsRead));
184    registry.register(Arc::new(FsList));
185    if write_enabled {
186        registry.register(Arc::new(FsWrite));
187        registry.register(Arc::new(Edit));
188        registry.register(Arc::new(MultiEdit));
189        registry.register(Arc::new(Search));
190        registry.register(Arc::new(Git));
191        registry.register(Arc::new(Exec::new(workspace.sandbox.clone())));
192    }
193    Arc::new(registry)
194}
195
196fn tool_blocks_text(blocks: &[Block]) -> String {
197    blocks
198        .iter()
199        .map(|block| match block {
200            Block::Text(text) => text.clone(),
201            Block::Json(value) => value.to_string(),
202            Block::Image { mime, data } => format!("[image: {}, {} bytes]", mime, data.len()),
203            Block::Diff { file, patch } => format!("diff for {}\n{}", file, patch),
204        })
205        .collect::<Vec<_>>()
206        .join("\n")
207}
208
209fn track_tool_diff(
210    diffs: &mut Vec<crate::event::FileDiff>,
211    tool_name: &str,
212    args: &serde_json::Value,
213    blocks: &[Block],
214) {
215    for block in blocks {
216        if let Block::Diff { file, patch } = block {
217            let plus = patch
218                .lines()
219                .filter(|line| line.starts_with('+') && !line.starts_with("+++"))
220                .count() as u32;
221            let minus = patch
222                .lines()
223                .filter(|line| line.starts_with('-') && !line.starts_with("---"))
224                .count() as u32;
225            if !diffs.iter().any(|diff| diff.file == *file) {
226                diffs.push(crate::event::FileDiff {
227                    file: file.clone(),
228                    plus,
229                    minus,
230                });
231            }
232        }
233    }
234
235    if matches!(tool_name, "fs_write" | "edit" | "multi_edit") {
236        if let Some(path) = args.get("path").and_then(|value| value.as_str()) {
237            if !diffs.iter().any(|diff| diff.file == path) {
238                diffs.push(crate::event::FileDiff {
239                    file: path.to_string(),
240                    plus: 0,
241                    minus: 0,
242                });
243            }
244        }
245    }
246}
247
248// ─── THE ORCHESTRATOR TRAIT ─────────────────────────────────────────────────────
249
250#[async_trait::async_trait]
251pub trait Orchestrator: Send + Sync {
252    async fn run_swarm(
253        &self,
254        plan: SwarmPlan,
255        event_tx: mpsc::UnboundedSender<Event>,
256    ) -> anyhow::Result<SwarmOutcome>;
257}
258
259// ─── Default orchestrator: Planner → Coder → Verifier ───────────────────────────
260
261pub struct DefaultOrchestrator {
262    router: Arc<dyn Router>,
263    config: Config,
264    memory: Arc<dyn Memory>,
265    file_locks: Arc<FileLocks>,
266}
267
268impl DefaultOrchestrator {
269    pub fn new(router: Arc<dyn Router>, config: Config, memory: Arc<dyn Memory>) -> Self {
270        Self {
271            router,
272            config,
273            memory,
274            file_locks: Arc::new(FileLocks::new()),
275        }
276    }
277
278    /// Classify task tier for model selection
279    fn classify(&self, task: &str) -> TaskTier {
280        let lower = task.to_lowercase();
281        if lower.len() < 20 {
282            TaskTier::Trivial
283        } else if lower.contains("refactor") || lower.contains("architecture") {
284            TaskTier::Hard
285        } else if lower.contains("bug") || lower.contains("fix") {
286            TaskTier::Small
287        } else {
288            TaskTier::Medium
289        }
290    }
291
292    /// Select a brain for a given role and tier
293    fn select_brain(&self, role: &str, tier: TaskTier) -> Option<Arc<dyn Brain>> {
294        let need = match role {
295            "planner" => crate::router::RoutingNeed {
296                tier: TaskTier::Hard, // Planner always uses a strong model
297                required_tools: false,
298                required_vision: false,
299                prefer_local: false,
300            },
301            "verifier" => crate::router::RoutingNeed {
302                tier: TaskTier::Medium, // Verifier uses medium model
303                required_tools: true,
304                required_vision: false,
305                prefer_local: false,
306            },
307            _ => crate::router::RoutingNeed {
308                // Coder writes files via tools — it must get a tool-capable,
309                // competent model. A "trivial"/"small" classification would route
310                // to the cheapest free model that often emits prose instead of
311                // tool calls (no diff lands). Floor the coder at Medium.
312                tier: match tier {
313                    TaskTier::Trivial | TaskTier::Small => TaskTier::Medium,
314                    other => other,
315                },
316                required_tools: true,
317                required_vision: false,
318                prefer_local: false,
319            },
320        };
321
322        let budget = BudgetState {
323            daily_limit_usd: self.config.budget.daily_usd,
324            daily_spent_usd: 0.0,
325            session_limit_usd: self.config.budget.session_usd,
326            session_spent_usd: 0.0,
327        };
328
329        // Wrap the whole fallback chain so a 404/ratelimit on the top model
330        // transparently advances to the next instead of killing the role.
331        let chain = self.router.select(&need, &budget);
332        if chain.is_empty() {
333            None
334        } else {
335            Some(Arc::new(FallbackBrain::new(chain)) as Arc<dyn Brain>)
336        }
337    }
338
339    /// Run the planner agent
340    async fn run_planner(
341        &self,
342        task: &str,
343        workspace: &Workspace,
344        brain: Arc<dyn Brain>,
345        event_tx: &mpsc::UnboundedSender<Event>,
346        parent_run: &RunId,
347    ) -> anyhow::Result<(String, f64, TokenUsage)> {
348        let planner_identity = Identity {
349            name: "planner".into(),
350            role: "technical architect and planner".into(),
351            personality: "analytical, thorough, produces clear structured plans with concrete steps and acceptance criteria.".into(),
352        };
353
354        let system = format!(
355            r#"You are the PLANNER agent in a swarm.
356
357{personality}
358
359Your job: take a task and produce a detailed implementation SPEC.
360- Break the task into clear, numbered steps.
361- For each step, specify what files to create/modify.
362- Include acceptance criteria for the verifier.
363- Output ONLY the spec. No code. No implementation.
364
365Output format:
366## SPEC: <title>
367
368### Step 1: <description>
369- Files: <list>
370- Changes: <what changes>
371- Acceptance: <verification criteria>
372
373### Step 2: ...
374"#,
375            personality = planner_identity.personality,
376        );
377
378        let messages = vec![Msg {
379            role: "user".into(),
380            content: vec![ContentBlock::Text {
381                text: format!("Task to plan:\n\n{}", task),
382            }],
383        }];
384
385        let tools = swarm_tool_registry(workspace, false);
386
387        let tool_specs = tools.to_specs();
388        let req = BrainRequest {
389            system: Some(system),
390            messages,
391            tools: tool_specs.clone(),
392            max_tokens: 4096,
393            temperature: 0.0,
394            stop: vec![],
395            cache: PromptCacheConfig::enabled(Some(prompt_cache_key(
396                "swarm-planner",
397                &workspace.root,
398                &tool_specs,
399            ))),
400        };
401
402        let _ = event_tx.send(Event::AgentStatus {
403            run: parent_run.clone(),
404            role: "planner".into(),
405            status: AgentStatus::Thinking,
406            note: format!("planning with {}", brain.id()),
407        });
408
409        let mut stream = brain.complete(req).await?;
410        let mut plan = String::new();
411        let caps = brain.caps();
412        let mut cost = 0.0_f64;
413        let mut tokens = TokenUsage {
414            input: 0,
415            output: 0,
416        };
417
418        while let Some(ev) = futures::StreamExt::next(&mut stream).await {
419            match ev {
420                crate::provider::BrainEvent::TextDelta(text) => {
421                    plan.push_str(&text);
422                    let _ = event_tx.send(Event::ThinkingDelta {
423                        run: parent_run.clone(),
424                        text,
425                    });
426                }
427                crate::provider::BrainEvent::Usage(usage) => {
428                    tokens.input = tokens.input.saturating_add(usage.input);
429                    tokens.output = tokens.output.saturating_add(usage.output);
430                    cost += caps.cost_input_per_mtok * (usage.input as f64) / 1_000_000.0
431                        + caps.cost_output_per_mtok * (usage.output as f64) / 1_000_000.0;
432                }
433                crate::provider::BrainEvent::Done(_) => break,
434                crate::provider::BrainEvent::Error(e) => {
435                    anyhow::bail!("Planner error: {}", e)
436                }
437                _ => {}
438            }
439        }
440
441        let _ = event_tx.send(Event::AgentStatus {
442            run: parent_run.clone(),
443            role: "planner".into(),
444            status: AgentStatus::Done,
445            note: "plan complete".into(),
446        });
447
448        Ok((plan, cost, tokens))
449    }
450
451    /// Run the coder agent with a given spec
452    async fn run_coder(
453        &self,
454        spec: &str,
455        rework_notes: Option<&[String]>,
456        workspace: &Workspace,
457        brain: Arc<dyn Brain>,
458        event_tx: &mpsc::UnboundedSender<Event>,
459        parent_run: &RunId,
460    ) -> anyhow::Result<(Vec<crate::event::FileDiff>, f64, TokenUsage)> {
461        let coder_identity = Identity {
462            name: "coder".into(),
463            role: "implementation engineer".into(),
464            personality:
465                "precise, produces clean working code, uses exact file edits with the edit tool."
466                    .into(),
467        };
468
469        let rework_section = if let Some(notes) = rework_notes {
470            if notes.is_empty() {
471                String::new()
472            } else {
473                format!(
474                    "\n## REWORK NOTES (from verifier)\nThe previous implementation had issues. Fix these:\n{}",
475                    notes
476                        .iter()
477                        .enumerate()
478                        .map(|(i, n)| format!("{}. {}", i + 1, n))
479                        .collect::<Vec<_>>()
480                        .join("\n")
481                )
482            }
483        } else {
484            String::new()
485        };
486
487        let system = format!(
488            r#"You are the CODER agent in a swarm.
489
490{}
491
492Your job: implement the SPEC exactly. Use tools to read existing files and write edits.
493- Follow the spec steps in order.
494- Use the edit or fs_write tool to make changes.
495- After each file edit, note what you changed.
496- Produce working, compilable code.
497{}
498"#,
499            coder_identity.personality, rework_section,
500        );
501
502        // Build repo map context
503        let repo_map = self.memory.repo_map(&workspace.root);
504        let file_list: Vec<String> = repo_map
505            .files
506            .iter()
507            .map(|f| format!("  {}", f.path))
508            .collect();
509
510        let context_msg = format!(
511            "## SPEC TO IMPLEMENT\n\n{}\n\n## WORKSPACE FILES\n{}",
512            spec,
513            file_list.join("\n"),
514        );
515
516        let mut messages = vec![Msg {
517            role: "user".into(),
518            content: vec![ContentBlock::Text { text: context_msg }],
519        }];
520        let tools = swarm_tool_registry(workspace, true);
521        let tool_specs = tools.to_specs();
522
523        let _ = event_tx.send(Event::AgentStatus {
524            run: parent_run.clone(),
525            role: "coder".into(),
526            status: AgentStatus::Working,
527            note: format!("implementing with {}", brain.id()),
528        });
529
530        let mut output = String::new();
531        let mut diffs = Vec::new();
532        let caps = brain.caps();
533        let mut cost = 0.0_f64;
534        let mut tokens = TokenUsage {
535            input: 0,
536            output: 0,
537        };
538
539        for _turn in 0..8 {
540            let req = BrainRequest {
541                system: Some(system.clone()),
542                messages: messages.clone(),
543                tools: tool_specs.clone(),
544                max_tokens: 8192,
545                temperature: 0.0,
546                stop: vec![],
547                cache: PromptCacheConfig::enabled(Some(prompt_cache_key(
548                    "swarm-coder",
549                    &workspace.root,
550                    &tool_specs,
551                ))),
552            };
553
554            let mut stream = brain.complete(req).await?;
555            let mut assistant_text = String::new();
556            let mut assistant_blocks = Vec::new();
557            let mut tool_result_blocks = Vec::new();
558            let mut current_tool_id = String::new();
559            let mut current_tool_name = String::new();
560            let mut current_tool_json = String::new();
561
562            while let Some(ev) = futures::StreamExt::next(&mut stream).await {
563                match ev {
564                    crate::provider::BrainEvent::TextDelta(text) => {
565                        output.push_str(&text);
566                        assistant_text.push_str(&text);
567                        let _ = event_tx.send(Event::ThinkingDelta {
568                            run: parent_run.clone(),
569                            text,
570                        });
571                    }
572                    // The orchestrator's coder loop doesn't need to round-trip
573                    // reasoning content (each phase uses a fresh BrainRequest),
574                    // so we just swallow it here to keep the match exhaustive.
575                    crate::provider::BrainEvent::ReasoningDelta(_) => {}
576                    crate::provider::BrainEvent::ToolUseStart { id, name } => {
577                        current_tool_id = id.clone();
578                        current_tool_name = name.clone();
579                        current_tool_json.clear();
580                        let risk = tools
581                            .get(&name)
582                            .map(|tool| tool.risk())
583                            .unwrap_or(RiskLevel::ReadOnly);
584                        let _ = event_tx.send(Event::ToolUseProposed {
585                            run: parent_run.clone(),
586                            id,
587                            name,
588                            args: serde_json::json!({}),
589                            risk,
590                        });
591                    }
592                    crate::provider::BrainEvent::ToolUseDelta { id: _, json } => {
593                        current_tool_json.push_str(&json);
594                    }
595                    crate::provider::BrainEvent::ToolUseEnd { id } => {
596                        let args = serde_json::from_str::<serde_json::Value>(&current_tool_json)
597                            .unwrap_or_else(|_| serde_json::json!({}));
598                        let tool_name = if current_tool_name.is_empty() {
599                            "unknown".to_string()
600                        } else {
601                            current_tool_name.clone()
602                        };
603                        assistant_blocks.push(ContentBlock::ToolUse {
604                            id: id.clone(),
605                            name: tool_name.clone(),
606                            input: args.clone(),
607                        });
608                        let _ = event_tx.send(Event::ToolUseStarted {
609                            run: parent_run.clone(),
610                            id: id.clone(),
611                        });
612                        let result = if let Some(tool) = tools.get(&tool_name) {
613                            let ctx = ToolCtx {
614                                workspace_root: workspace.root.clone(),
615                                run_id: parent_run.clone(),
616                            };
617                            match tool.call(args.clone(), &ctx).await {
618                                Ok(result) => result,
619                                Err(err) => crate::tools::ToolResult::error(format!(
620                                    "Tool {} failed: {}",
621                                    tool_name, err
622                                )),
623                            }
624                        } else {
625                            crate::tools::ToolResult::error(format!("Unknown tool: {}", tool_name))
626                        };
627                        track_tool_diff(&mut diffs, &tool_name, &args, &result.content);
628                        for diff in &diffs {
629                            let _ = event_tx.send(Event::DiffProposed {
630                                run: parent_run.clone(),
631                                file: diff.file.clone(),
632                                patch: String::new(),
633                                plus: diff.plus,
634                                minus: diff.minus,
635                            });
636                        }
637                        let blocks = result.content.clone();
638                        let text = tool_blocks_text(&blocks);
639                        let _ = event_tx.send(Event::ToolOutput {
640                            run: parent_run.clone(),
641                            id: id.clone(),
642                            blocks,
643                            is_error: result.is_error,
644                        });
645                        tool_result_blocks.push(ContentBlock::ToolResult {
646                            tool_use_id: id,
647                            content: vec![ContentBlock::Text { text }],
648                            is_error: Some(result.is_error),
649                        });
650                        current_tool_id.clear();
651                        current_tool_name.clear();
652                        current_tool_json.clear();
653                    }
654                    crate::provider::BrainEvent::Done(_) => break,
655                    crate::provider::BrainEvent::Error(e) => anyhow::bail!("Coder error: {}", e),
656                    crate::provider::BrainEvent::Usage(usage) => {
657                        tokens.input = tokens.input.saturating_add(usage.input);
658                        tokens.output = tokens.output.saturating_add(usage.output);
659                        cost += caps.cost_input_per_mtok * (usage.input as f64) / 1_000_000.0
660                            + caps.cost_output_per_mtok * (usage.output as f64) / 1_000_000.0;
661                    }
662                }
663            }
664
665            if !assistant_text.is_empty() {
666                assistant_blocks.insert(
667                    0,
668                    ContentBlock::Text {
669                        text: assistant_text,
670                    },
671                );
672            }
673            if tool_result_blocks.is_empty() {
674                break;
675            }
676            messages.push(Msg {
677                role: "assistant".into(),
678                content: assistant_blocks,
679            });
680            messages.push(Msg {
681                role: "user".into(),
682                content: tool_result_blocks,
683            });
684        }
685
686        if diffs.is_empty() {
687            for line in output.lines() {
688                if let Some(file) = line.strip_prefix("Edited ") {
689                    let file = file
690                        .trim()
691                        .split(':')
692                        .next()
693                        .unwrap_or("")
694                        .trim()
695                        .to_string();
696                    if !file.is_empty() && !diffs.iter().any(|d| d.file == file) {
697                        diffs.push(crate::event::FileDiff {
698                            file,
699                            plus: 1,
700                            minus: 1,
701                        });
702                    }
703                }
704            }
705        }
706
707        // Emit diff events
708        for diff in &diffs {
709            let _ = event_tx.send(Event::DiffProposed {
710                run: parent_run.clone(),
711                file: diff.file.clone(),
712                patch: String::new(),
713                plus: diff.plus,
714                minus: diff.minus,
715            });
716        }
717
718        let _ = event_tx.send(Event::AgentStatus {
719            run: parent_run.clone(),
720            role: "coder".into(),
721            status: AgentStatus::Done,
722            note: format!("{} files changed", diffs.len()),
723        });
724
725        Ok((diffs, cost, tokens))
726    }
727
728    /// Run the verifier agent
729    async fn run_verifier(
730        &self,
731        spec: &str,
732        diffs: &[crate::event::FileDiff],
733        workspace: &Workspace,
734        brain: Arc<dyn Brain>,
735        event_tx: &mpsc::UnboundedSender<Event>,
736        parent_run: &RunId,
737    ) -> anyhow::Result<(Verdict, f64, TokenUsage)> {
738        let verifier_identity = Identity {
739            name: "verifier".into(),
740            role: "code reviewer and quality assurance".into(),
741            personality: "adversarial, meticulous, catches issues the coder missed. Checks correctness, style, edge cases, and spec compliance.".into(),
742        };
743
744        let diff_summary: String = diffs
745            .iter()
746            .map(|d| format!("  {}: +{} -{}", d.file, d.plus, d.minus))
747            .collect::<Vec<_>>()
748            .join("\n");
749
750        // ── Ground-truth gate (#2) ──────────────────────────────────────────
751        // Before asking an LLM for its opinion, actually BUILD/TEST the
752        // workspace (explicit `verify_command`, else an auto-detected runner).
753        // A real failure is REWORK with no LLM override — the compiler outranks
754        // the model. A real pass is fed into the review so the LLM judges spec
755        // and style on top of a known-green baseline instead of guessing.
756        let ground_truth = crate::project_test::run_verification(
757            &workspace.root,
758            self.config.defaults.verify_command.as_deref(),
759        )
760        .await;
761        let build_test_block = match &ground_truth {
762            Some(o) if !o.passed => {
763                let _ = event_tx.send(Event::AgentStatus {
764                    run: parent_run.clone(),
765                    role: "verifier".into(),
766                    status: AgentStatus::Working,
767                    note: format!("ground-truth `{}` FAILED → rework", o.command),
768                });
769                return Ok((
770                    Verdict::Rework {
771                        findings: vec![format!(
772                            "Ground-truth check `{}` FAILED — fix this before anything else:\n{}",
773                            o.command, o.summary
774                        )],
775                    },
776                    0.0,
777                    TokenUsage {
778                        input: 0,
779                        output: 0,
780                    },
781                ));
782            }
783            Some(o) => format!("## BUILD / TEST (ground truth)\n`{}` PASSED ✓\n", o.command),
784            None => "## BUILD / TEST (ground truth)\nNo build/test command available — this review is best-effort (no executable signal).\n".to_string(),
785        };
786
787        let tools = swarm_tool_registry(workspace, false);
788        let read_tool = tools.get("fs_read");
789        let mut files_to_check = Vec::new();
790        for d in diffs {
791            let content = if let Some(tool) = &read_tool {
792                let ctx = ToolCtx {
793                    workspace_root: workspace.root.clone(),
794                    run_id: parent_run.clone(),
795                };
796                let args = serde_json::json!({ "path": d.file, "limit": 220 });
797                match tool.call(args, &ctx).await {
798                    Ok(result) => tool_blocks_text(&result.content),
799                    Err(_) => format!("[cannot read {}]", d.file),
800                }
801            } else {
802                format!("[cannot read {}]", d.file)
803            };
804            files_to_check.push(content);
805        }
806
807        let files_context: String = diffs
808            .iter()
809            .zip(files_to_check.iter())
810            .map(|(d, content)| {
811                format!(
812                    "### {}\n```\n{}\n```",
813                    d.file,
814                    if content.len() > 3000 {
815                        format!("{}... [truncated]", &content[..3000])
816                    } else {
817                        content.clone()
818                    }
819                )
820            })
821            .collect::<Vec<_>>()
822            .join("\n\n");
823
824        let system = format!(
825            r#"You are the VERIFIER agent in a swarm.
826
827{personality}
828
829The BUILD / TEST section is ground truth — it actually ran. If it PASSED, the
830code compiles and tests are green: do NOT invent build/compile failures; focus on
831spec compliance, correctness, edge cases, and style. If it's best-effort (no
832runner), be extra careful about correctness yourself.
833
834Your job: review the coder's implementation against the SPEC.
835- For each spec requirement, check if it's satisfied.
836- Find bugs, style issues, missing edge cases, spec violations.
837- Output EXACTLY one of:
838  ✓ PASS — if everything is correct and complete.
839  ✗ REWORK — followed by numbered concrete findings.
840
841Format:
842✓ PASS
843(no issues found)
844
845or:
846
847✗ REWORK
8481. <specific finding with file:line>
8492. <another finding>
850"#,
851            personality = verifier_identity.personality,
852        );
853
854        let context = format!(
855            "{}\n## SPEC\n{}\n\n## CHANGED FILES\n{}\n\n## FILE CONTENTS\n{}",
856            build_test_block, spec, diff_summary, files_context,
857        );
858
859        let messages = vec![Msg {
860            role: "user".into(),
861            content: vec![ContentBlock::Text { text: context }],
862        }];
863
864        let tool_specs = tools.to_specs();
865        let req = BrainRequest {
866            system: Some(system),
867            messages,
868            tools: tool_specs.clone(),
869            max_tokens: 4096,
870            temperature: 0.0,
871            stop: vec![],
872            cache: PromptCacheConfig::enabled(Some(prompt_cache_key(
873                "swarm-verifier",
874                &workspace.root,
875                &tool_specs,
876            ))),
877        };
878
879        let _ = event_tx.send(Event::AgentStatus {
880            run: parent_run.clone(),
881            role: "verifier".into(),
882            status: AgentStatus::Working,
883            note: format!("reviewing with {}", brain.id()),
884        });
885
886        let mut stream = brain.complete(req).await?;
887        let mut verdict_text = String::new();
888        let caps = brain.caps();
889        let mut cost = 0.0_f64;
890        let mut tokens = TokenUsage {
891            input: 0,
892            output: 0,
893        };
894
895        while let Some(ev) = futures::StreamExt::next(&mut stream).await {
896            match ev {
897                crate::provider::BrainEvent::TextDelta(text) => {
898                    verdict_text.push_str(&text);
899                }
900                crate::provider::BrainEvent::Usage(usage) => {
901                    tokens.input = tokens.input.saturating_add(usage.input);
902                    tokens.output = tokens.output.saturating_add(usage.output);
903                    cost += caps.cost_input_per_mtok * (usage.input as f64) / 1_000_000.0
904                        + caps.cost_output_per_mtok * (usage.output as f64) / 1_000_000.0;
905                }
906                crate::provider::BrainEvent::Done(_) => break,
907                _ => {}
908            }
909        }
910
911        // Case-insensitive PASS/REWORK detection. "PASS" wins only if no rework signal.
912        let upper = verdict_text.to_uppercase();
913        let has_rework = upper.contains("REWORK")
914            || upper.contains("NEEDS REWORK")
915            || verdict_text.contains("✗");
916        let has_pass = (upper.contains("PASS") || verdict_text.contains("✓")) && !has_rework;
917
918        let verdict = if has_rework {
919            let findings: Vec<String> = verdict_text
920                .lines()
921                .filter(|l| l.trim().starts_with(|c: char| c.is_ascii_digit()) && l.contains('.'))
922                .map(|l| l.trim().to_string())
923                .collect();
924
925            if findings.is_empty() {
926                Verdict::Rework {
927                    findings: vec![verdict_text.clone()],
928                }
929            } else {
930                Verdict::Rework { findings }
931            }
932        } else if has_pass {
933            Verdict::Pass
934        } else {
935            // No clear verdict — treat as rework with the raw text to be safe.
936            Verdict::Rework {
937                findings: vec![format!("Verifier verdict unclear: {}", verdict_text)],
938            }
939        };
940
941        let _ = event_tx.send(Event::AgentStatus {
942            run: parent_run.clone(),
943            role: "verifier".into(),
944            status: AgentStatus::Done,
945            note: match &verdict {
946                Verdict::Pass => "PASS".into(),
947                Verdict::Rework { findings } => format!("REWORK ({} issues)", findings.len()),
948            },
949        });
950
951        Ok((verdict, cost, tokens))
952    }
953}
954
955#[async_trait::async_trait]
956impl Orchestrator for DefaultOrchestrator {
957    async fn run_swarm(
958        &self,
959        plan: SwarmPlan,
960        event_tx: mpsc::UnboundedSender<Event>,
961    ) -> anyhow::Result<SwarmOutcome> {
962        let run_id = RunId::new();
963        let task = plan.task.clone();
964
965        let _ = event_tx.send(Event::RunStarted {
966            run: run_id.clone(),
967            task: task.clone(),
968            agent: "swarm".into(),
969        });
970
971        // Select brains for each role
972        let planner_brain = self
973            .select_brain("planner", self.classify(&task))
974            .ok_or_else(|| anyhow::anyhow!("No model available for planner"))?;
975
976        let coder_brain = self
977            .select_brain("coder", self.classify(&task))
978            .unwrap_or_else(|| planner_brain.clone());
979
980        let verifier_brain = self
981            .select_brain("verifier", self.classify(&task))
982            .unwrap_or_else(|| coder_brain.clone());
983
984        // Create workspace
985        let sandbox = LocalSandbox::new(plan.workspace.clone());
986        let workspace = Workspace {
987            root: plan.workspace.clone(),
988            sandbox: Arc::new(sandbox),
989        };
990
991        let mut total_cost: f64 = 0.0;
992        let mut total_tokens = TokenUsage {
993            input: 0,
994            output: 0,
995        };
996
997        // ▸ PHASE 1: PLANNING
998        let _ = event_tx.send(Event::AgentSpawned {
999            run: run_id.clone(),
1000            role: "planner".into(),
1001            model: planner_brain.id().to_string(),
1002        });
1003
1004        let (spec, planner_cost, planner_tokens) = self
1005            .run_planner(&task, &workspace, planner_brain, &event_tx, &run_id)
1006            .await?;
1007        total_cost += planner_cost;
1008        total_tokens.input = total_tokens.input.saturating_add(planner_tokens.input);
1009        total_tokens.output = total_tokens.output.saturating_add(planner_tokens.output);
1010        let _ = event_tx.send(Event::CostUpdate {
1011            run: run_id.clone(),
1012            usd: total_cost,
1013        });
1014
1015        // Post plan to shared memory
1016        let _ = self.memory.upsert_doc(crate::memory::WorkingDoc {
1017            id: format!("plan-{}", run_id.0),
1018            title: format!("Plan: {}", &task[..task.len().min(60)]),
1019            content: spec.clone(),
1020            updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1021        });
1022
1023        // ▸ PHASE 2–3: CODING + VERIFYING (with REWORK loop)
1024        let _ = event_tx.send(Event::AgentSpawned {
1025            run: run_id.clone(),
1026            role: "coder".into(),
1027            model: coder_brain.id().to_string(),
1028        });
1029
1030        let _ = event_tx.send(Event::AgentSpawned {
1031            run: run_id.clone(),
1032            role: "verifier".into(),
1033            model: verifier_brain.id().to_string(),
1034        });
1035
1036        let mut rework_notes: Option<Vec<String>> = None;
1037        let mut all_diffs: Vec<crate::event::FileDiff> = Vec::new();
1038        let mut reworks = 0u32;
1039        let mut passes = 0u32;
1040
1041        for attempt in 0..=plan.max_reworks {
1042            // ▸ CODER
1043            let (diffs, coder_cost, coder_tokens) = self
1044                .run_coder(
1045                    &spec,
1046                    rework_notes.as_deref(),
1047                    &workspace,
1048                    coder_brain.clone(),
1049                    &event_tx,
1050                    &run_id,
1051                )
1052                .await?;
1053            total_cost += coder_cost;
1054            total_tokens.input = total_tokens.input.saturating_add(coder_tokens.input);
1055            total_tokens.output = total_tokens.output.saturating_add(coder_tokens.output);
1056            let _ = event_tx.send(Event::CostUpdate {
1057                run: run_id.clone(),
1058                usd: total_cost,
1059            });
1060
1061            // Release any locks from previous attempt
1062            if attempt > 0 {
1063                let prev_files: Vec<String> = all_diffs.iter().map(|d| d.file.clone()).collect();
1064                self.file_locks.release(&prev_files).await;
1065            }
1066
1067            // Acquire locks for new diffs
1068            let new_files: Vec<String> = diffs.iter().map(|d| d.file.clone()).collect();
1069            if let Err(conflicts) = self.file_locks.try_lock(&new_files).await {
1070                let _ = event_tx.send(Event::Error {
1071                    run: run_id.clone(),
1072                    message: format!("File collision detected: {:?}", conflicts),
1073                });
1074            }
1075
1076            all_diffs = diffs.clone();
1077
1078            // ▸ Empty-diff guard: if the coder changed no files, don't waste a
1079            // verify pass (and never let it PASS) — force a REWORK with an
1080            // explicit instruction to actually call the tools. This rescues weak
1081            // models that describe a change in prose instead of emitting tool calls.
1082            if diffs.is_empty() && attempt < plan.max_reworks {
1083                reworks += 1;
1084                let note = "You made NO file changes. You MUST call the fs_write or edit \
1085                    tool to create/modify the files in the spec — do not merely describe \
1086                    the change in prose. Emit the tool call now."
1087                    .to_string();
1088                rework_notes = Some(vec![note.clone()]);
1089                let _ = event_tx.send(Event::TestResult {
1090                    run: run_id.clone(),
1091                    passed: 0,
1092                    failed: 1,
1093                    detail: "no file changes — coder must use tools".into(),
1094                });
1095                let _ = event_tx.send(Event::AgentStatus {
1096                    run: run_id.clone(),
1097                    role: "coder".into(),
1098                    status: AgentStatus::Working,
1099                    note: format!("no diff — forcing tool use (attempt {})", attempt + 1),
1100                });
1101                continue;
1102            }
1103
1104            // ▸ VERIFIER
1105            let (verdict, verifier_cost, verifier_tokens) = self
1106                .run_verifier(
1107                    &spec,
1108                    &diffs,
1109                    &workspace,
1110                    verifier_brain.clone(),
1111                    &event_tx,
1112                    &run_id,
1113                )
1114                .await?;
1115            total_cost += verifier_cost;
1116            total_tokens.input = total_tokens.input.saturating_add(verifier_tokens.input);
1117            total_tokens.output = total_tokens.output.saturating_add(verifier_tokens.output);
1118            let _ = event_tx.send(Event::CostUpdate {
1119                run: run_id.clone(),
1120                usd: total_cost,
1121            });
1122
1123            match verdict {
1124                Verdict::Pass => {
1125                    passes += 1;
1126                    // Signal PASS to shared memory
1127                    let _ = self.memory.post_signal(crate::memory::SharedSignal {
1128                        id: format!("pass-{}-{}", run_id.0, attempt),
1129                        from_agent: "verifier".into(),
1130                        to_agent: "coder".into(),
1131                        content: "PASS — all checks satisfied".into(),
1132                        timestamp: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1133                    });
1134
1135                    let _ = event_tx.send(Event::TestResult {
1136                        run: run_id.clone(),
1137                        passed: 1,
1138                        failed: 0,
1139                        detail: "Verifier PASS".into(),
1140                    });
1141                    for diff in &diffs {
1142                        let _ = event_tx.send(Event::DiffApplied {
1143                            run: run_id.clone(),
1144                            file: diff.file.clone(),
1145                        });
1146                    }
1147                    break; // Done!
1148                }
1149                Verdict::Rework { findings } => {
1150                    reworks += 1;
1151                    rework_notes = Some(findings.clone());
1152
1153                    // Signal REWORK to shared memory
1154                    let _ = self.memory.post_signal(crate::memory::SharedSignal {
1155                        id: format!("rework-{}-{}", run_id.0, attempt),
1156                        from_agent: "verifier".into(),
1157                        to_agent: "coder".into(),
1158                        content: format!("REWORK: {}", findings.join("; ")),
1159                        timestamp: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1160                    });
1161
1162                    let _ = event_tx.send(Event::TestResult {
1163                        run: run_id.clone(),
1164                        passed: 0,
1165                        failed: findings.len() as u32,
1166                        detail: findings.join("\n"),
1167                    });
1168
1169                    let _ = event_tx.send(Event::AgentStatus {
1170                        run: run_id.clone(),
1171                        role: "coder".into(),
1172                        status: AgentStatus::Working,
1173                        note: format!("rework attempt {}/{}", attempt + 1, plan.max_reworks),
1174                    });
1175                }
1176            }
1177        }
1178
1179        let outcome = SwarmOutcome {
1180            status: if passes > 0 {
1181                "PASS".into()
1182            } else {
1183                format!("FAILED after {} reworks", reworks)
1184            },
1185            plan: Some(spec),
1186            diffs: all_diffs,
1187            passes,
1188            reworks,
1189            cost_usd: total_cost,
1190        };
1191
1192        let cost_comparison = crate::cost::format_comparison(outcome.cost_usd, &total_tokens);
1193        let outcome_summary = OutcomeSummary {
1194            status: outcome.status.clone(),
1195            diffs: outcome.diffs.clone(),
1196            cost_usd: outcome.cost_usd,
1197            tokens: total_tokens,
1198            cost_comparison,
1199            duration_ms: None,
1200        };
1201
1202        let _ = event_tx.send(Event::RunFinished {
1203            run: run_id,
1204            outcome: outcome_summary,
1205        });
1206
1207        Ok(outcome)
1208    }
1209}