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
36struct 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#[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
137pub 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#[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
259pub 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 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 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, required_tools: false,
298 required_vision: false,
299 prefer_local: false,
300 },
301 "verifier" => crate::router::RoutingNeed {
302 tier: TaskTier::Medium, required_tools: true,
304 required_vision: false,
305 prefer_local: false,
306 },
307 _ => crate::router::RoutingNeed {
308 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 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 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 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 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 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>(¤t_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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; }
1149 Verdict::Rework { findings } => {
1150 reworks += 1;
1151 rework_notes = Some(findings.clone());
1152
1153 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}