Skip to main content

recursive/
multi.rs

1//! Multi-agent orchestration: agent pool, role definitions, and message bus.
2
3use crate::kernel::{AgentKernel, TurnContext, TurnOutcome};
4use crate::message::Message;
5use crate::permissions::PermissionMode;
6use crate::tasks::TaskRegistry;
7use crate::tools::{
8    AgentDefinitions, AgentTool, ListWorkersTool, SendMessageTool, ToolRegistry, WorkerRegistry,
9};
10#[cfg(feature = "coordinator-mode")]
11use crate::tools::{
12    TaskCreateTool, TaskGetTool, TaskListTool, TaskOutputTool, TaskStopTool, TaskUpdateTool,
13};
14use crate::{ChatProvider, Config};
15use std::collections::{HashMap, VecDeque};
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::Arc;
18use std::time::{SystemTime, UNIX_EPOCH};
19use tokio::sync::{broadcast, RwLock};
20
21/// Shared memory store for multi-agent coordination.
22#[derive(Clone)]
23pub struct SharedMemory {
24    store: Arc<RwLock<HashMap<String, MemoryEntry>>>,
25    seq: Arc<AtomicU64>,
26}
27
28/// A single entry in the shared memory store.
29///
30/// `seq` is a process-local monotonic counter assigned by `SharedMemory::set`.
31/// It exists so consumers can order entries deterministically even when
32/// wall-clock `timestamp` collides (same-second writes). Older serialised
33/// entries without the field deserialize to `seq: 0`.
34#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
35pub struct MemoryEntry {
36    pub key: String,
37    pub value: String,
38    pub author: String,
39    pub timestamp: u64,
40    #[serde(default)]
41    pub seq: u64,
42}
43
44impl SharedMemory {
45    pub fn new() -> Self {
46        Self {
47            store: Arc::new(RwLock::new(HashMap::new())),
48            seq: Arc::new(AtomicU64::new(1)),
49        }
50    }
51
52    pub async fn set(&self, key: String, value: String, author: String) {
53        let timestamp = SystemTime::now()
54            .duration_since(UNIX_EPOCH)
55            .unwrap_or_default()
56            .as_secs();
57        let seq = self.seq.fetch_add(1, Ordering::Relaxed);
58        let entry = MemoryEntry {
59            key: key.clone(),
60            value,
61            author,
62            timestamp,
63            seq,
64        };
65        self.store.write().await.insert(key, entry);
66    }
67
68    pub async fn get(&self, key: &str) -> Option<MemoryEntry> {
69        self.store.read().await.get(key).cloned()
70    }
71
72    pub async fn keys(&self) -> Vec<String> {
73        self.store.read().await.keys().cloned().collect()
74    }
75
76    pub async fn all(&self) -> Vec<MemoryEntry> {
77        self.store.read().await.values().cloned().collect()
78    }
79
80    pub async fn remove(&self, key: &str) -> bool {
81        self.store.write().await.remove(key).is_some()
82    }
83
84    pub async fn to_context_string(&self) -> String {
85        let store = self.store.read().await;
86        if store.is_empty() {
87            return String::new();
88        }
89        let mut lines = vec!["[Shared Memory]".to_string()];
90        for entry in store.values() {
91            lines.push(format!(
92                "- {} = {} (by {})",
93                entry.key, entry.value, entry.author
94            ));
95        }
96        lines.join("\n")
97    }
98
99    pub async fn len(&self) -> usize {
100        self.store.read().await.len()
101    }
102
103    pub async fn is_empty(&self) -> bool {
104        self.store.read().await.is_empty()
105    }
106}
107
108impl Default for SharedMemory {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114// --- Inter-agent messaging ---
115
116/// Message type for inter-agent communication.
117#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
118pub enum MessageType {
119    Task,
120    Result,
121    Question,
122    Feedback,
123    Broadcast,
124}
125
126/// A message exchanged between agents via the message bus.
127#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
128pub struct AgentMessage {
129    pub id: String,
130    pub from: String,
131    pub to: String,
132    pub content: String,
133    pub msg_type: MessageType,
134    pub timestamp: u64,
135}
136
137/// Generate a unique message ID using blake3 hash of timestamp + atomic counter.
138fn generate_message_id() -> String {
139    static COUNTER: AtomicU64 = AtomicU64::new(0);
140
141    let count = COUNTER.fetch_add(1, Ordering::Relaxed);
142    let now = SystemTime::now()
143        .duration_since(UNIX_EPOCH)
144        .unwrap_or_default();
145    let input = format!("msg-{}-{}", now.as_nanos(), count);
146    let hash = blake3::hash(input.as_bytes());
147    hash.to_hex()[..16].to_string()
148}
149
150/// Get current timestamp as seconds since UNIX epoch.
151fn now_timestamp() -> u64 {
152    SystemTime::now()
153        .duration_since(UNIX_EPOCH)
154        .unwrap_or_default()
155        .as_secs()
156}
157
158/// Maximum messages retained in `MessageBus.messages` history.
159/// 1000 messages × ~200 bytes/msg ≈ 200 KiB — bounded for
160/// long-running pools while still giving plenty of recent
161/// context for the goal-judge and history inspection.
162pub const MESSAGE_BUS_CAPACITY: usize = 1000;
163
164/// An inter-agent message bus supporting publish/subscribe and history.
165#[derive(Clone)]
166pub struct MessageBus {
167    /// Bounded ring buffer of recent messages. Oldest evicted
168    /// on overflow. Capacity is `MESSAGE_BUS_CAPACITY` to bound
169    /// memory in long-running multi-agent pools.
170    messages: Arc<RwLock<VecDeque<AgentMessage>>>,
171    subscribers: Arc<RwLock<HashMap<String, broadcast::Sender<AgentMessage>>>>,
172    /// Maximum number of messages to retain. Defaults to
173    /// `MESSAGE_BUS_CAPACITY`; overridable via `with_capacity`.
174    capacity: usize,
175}
176
177impl MessageBus {
178    pub fn new() -> Self {
179        Self {
180            messages: Arc::new(RwLock::new(VecDeque::with_capacity(MESSAGE_BUS_CAPACITY))),
181            subscribers: Arc::new(RwLock::new(HashMap::new())),
182            capacity: MESSAGE_BUS_CAPACITY,
183        }
184    }
185
186    /// Send a message. Stores in history with bounded eviction and notifies
187    /// relevant subscribers.
188    pub async fn send(&self, msg: AgentMessage) {
189        {
190            let mut history = self.messages.write().await;
191            if history.len() >= self.capacity {
192                history.pop_front();
193            }
194            history.push_back(msg.clone());
195        }
196        let subs = self.subscribers.read().await;
197        if msg.to == "broadcast" {
198            for tx in subs.values() {
199                let _ = tx.send(msg.clone());
200            }
201        } else if let Some(tx) = subs.get(&msg.to) {
202            let _ = tx.send(msg);
203        }
204    }
205
206    /// Create a `MessageBus` with a custom capacity (for testing).
207    pub fn with_capacity(cap: usize) -> Self {
208        Self {
209            messages: Arc::new(RwLock::new(VecDeque::with_capacity(cap))),
210            subscribers: Arc::new(RwLock::new(HashMap::new())),
211            capacity: cap,
212        }
213    }
214
215    /// Subscribe to messages for a given role. Returns a broadcast receiver.
216    pub async fn subscribe(&self, role: &str) -> broadcast::Receiver<AgentMessage> {
217        let mut subs = self.subscribers.write().await;
218        let tx = subs.entry(role.to_string()).or_insert_with(|| {
219            let (tx, _) = broadcast::channel(64);
220            tx
221        });
222        tx.subscribe()
223    }
224
225    /// Get all messages addressed to this role (including broadcasts).
226    pub async fn inbox(&self, role: &str) -> Vec<AgentMessage> {
227        self.messages
228            .read()
229            .await
230            .iter()
231            .filter(|m| m.to == role || m.to == "broadcast")
232            .cloned()
233            .collect()
234    }
235
236    /// Get all messages sent by this role.
237    pub async fn outbox(&self, role: &str) -> Vec<AgentMessage> {
238        self.messages
239            .read()
240            .await
241            .iter()
242            .filter(|m| m.from == role)
243            .cloned()
244            .collect()
245    }
246
247    /// Get the full message history (bounded to `MESSAGE_BUS_CAPACITY`).
248    pub async fn history(&self) -> VecDeque<AgentMessage> {
249        self.messages.read().await.clone()
250    }
251
252    /// Clear all stored messages.
253    pub async fn clear(&self) {
254        self.messages.write().await.clear();
255    }
256}
257
258impl Default for MessageBus {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264/// Definition of an agent role.
265#[derive(Clone, Debug)]
266pub struct AgentRole {
267    pub name: String,
268    pub system_prompt: String,
269    pub max_steps: usize,
270    pub allowed_tools: Vec<String>,
271}
272
273/// Execution mode for the unified `agent` delegation tool.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum AgentMode {
276    /// Single worker: manifest must have exactly one entry.
277    Single,
278    /// All workers run concurrently (join_all). Read-only workers benefit most.
279    Parallel,
280    /// Workers run one after another, in manifest key order.
281    Sequential,
282}
283
284impl AgentMode {
285    pub fn parse(s: &str) -> Option<Self> {
286        match s {
287            "single" => Some(Self::Single),
288            "parallel" => Some(Self::Parallel),
289            "sequential" => Some(Self::Sequential),
290            _ => None,
291        }
292    }
293}
294
295/// Definition of a single worker entry within the agent manifest.
296#[derive(Clone, Debug)]
297pub struct WorkerManifestEntry {
298    pub system_prompt: String,
299    pub allowed_tools: Vec<String>,
300}
301
302/// Maps worker IDs to their role definitions. Required by the `agent` tool.
303pub type AgentManifest = HashMap<String, WorkerManifestEntry>;
304
305/// An agent pool manages multiple agents with different roles.
306pub struct AgentPool {
307    roles: HashMap<String, AgentRole>,
308    provider: Arc<dyn ChatProvider>,
309    memory: SharedMemory,
310    bus: MessageBus,
311}
312
313impl AgentPool {
314    pub fn new(provider: Arc<dyn ChatProvider>, _config: Config) -> Self {
315        Self {
316            roles: HashMap::new(),
317            provider,
318            memory: SharedMemory::new(),
319            bus: MessageBus::new(),
320        }
321    }
322
323    pub fn memory(&self) -> &SharedMemory {
324        &self.memory
325    }
326
327    pub fn bus(&self) -> &MessageBus {
328        &self.bus
329    }
330
331    pub fn add_role(&mut self, role: AgentRole) {
332        self.roles.insert(role.name.clone(), role);
333    }
334
335    pub fn get_role(&self, name: &str) -> Option<&AgentRole> {
336        self.roles.get(name)
337    }
338
339    pub fn role_names(&self) -> Vec<&str> {
340        self.roles.keys().map(|s| s.as_str()).collect()
341    }
342
343    pub fn role_count(&self) -> usize {
344        self.roles.len()
345    }
346
347    /// Remove a role from the pool.  Returns `true` if the role existed.
348    pub fn remove_role(&mut self, name: &str) -> bool {
349        self.roles.remove(name).is_some()
350    }
351
352    pub async fn run_with_role(
353        &self,
354        role_name: &str,
355        goal: &str,
356    ) -> Result<TurnOutcome, crate::Error> {
357        let role = self
358            .roles
359            .get(role_name)
360            .ok_or_else(|| crate::Error::Config {
361                message: format!("unknown role: {role_name}"),
362            })?;
363
364        let memory_ctx = self.memory.to_context_string().await;
365        let system_prompt = if memory_ctx.is_empty() {
366            role.system_prompt.clone()
367        } else {
368            format!("{}\n\n{}", role.system_prompt, memory_ctx)
369        };
370
371        let kernel = AgentKernel::builder()
372            .llm(self.provider.clone())
373            .max_steps(role.max_steps)
374            .build()?;
375
376        let ctx = TurnContext {
377            messages: Arc::new(vec![
378                Message::system(system_prompt),
379                Message::user(goal.to_string()),
380            ]),
381            step_events_tx: None,
382            tool_specs: kernel.tools().specs(),
383            streaming: false,
384            permission_hook: None,
385            exploring_plan_mode: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
386            permission_mode: PermissionMode::Default,
387            mailbox: None,
388            turn: 0,
389            prompt_segments: None,
390            wall_timeout_secs: 0,
391        };
392
393        kernel.run(ctx).await
394    }
395
396    /// Send a task message from one agent role to another.
397    pub async fn send_task(&self, from: &str, to: &str, content: &str) {
398        self.bus
399            .send(AgentMessage {
400                id: generate_message_id(),
401                from: from.to_string(),
402                to: to.to_string(),
403                content: content.to_string(),
404                msg_type: MessageType::Task,
405                timestamp: now_timestamp(),
406            })
407            .await;
408    }
409
410    /// Send a result message from one agent role to another.
411    pub async fn send_result(&self, from: &str, to: &str, content: &str) {
412        self.bus
413            .send(AgentMessage {
414                id: generate_message_id(),
415                from: from.to_string(),
416                to: to.to_string(),
417                content: content.to_string(),
418                msg_type: MessageType::Result,
419                timestamp: now_timestamp(),
420            })
421            .await;
422    }
423}
424
425/// Return the system prompt that enables a coordinator agent to autonomously
426/// design a specialist team and orchestrate their work.
427///
428/// The coordinator workflow:
429/// 1. Analyse the task and decide which specialists are needed
430/// 2. Call the `agent` tool with a `manifest` describing each specialist
431///    (`system_prompt` + `allowed_tools`, or a `definition` referencing a built-in
432///    role from `.recursive/agents/*.md`)
433/// 3. Dispatch via the tool's `mode`: `single`, `parallel`, or `sequential`
434/// 4. Synthesise and return the combined results
435///
436/// This prompt is injected in addition to any project-level context. It only
437/// teaches tools that are actually registered on the coordinator's tool
438/// registry by [`register_subagent_if_enabled`].
439pub fn coordinator_system_prompt() -> &'static str {
440    concat!(
441        "You are a coordinator agent. Your job is to decompose complex tasks and delegate them ",
442        "to specialist workers you design on the fly.\n\n",
443        "## Your tools\n\n",
444        "- **`agent`** — your primary tool. Spawns one or more specialist workers. Each worker ",
445        "  runs its own agent loop with a restricted tool set and an isolated transcript.\n",
446        "- **`send_message`** — push a follow-up message to a worker. For a background worker this ",
447        "drives a NEW turn on the same runtime, preserving its transcript/context (cross-turn ",
448        "continuation). Use `task_id` (returned by a background `agent` call) to address it.\n",
449        "- If present, **`task_list` / `task_output` / `task_stop`** let you inspect running tasks, ",
450        "drain their buffered output, or cancel them. (These are optional and may not be registered ",
451        "in every build; if a tool call is rejected as unknown, simply rely on `agent` and ",
452        "`send_message` instead.)\n\n",
453        "## Your workflow\n\n",
454        "1. **Analyse** — understand the task and identify the distinct subtasks and the expertise each requires.\n",
455        "2. **Design specialists** — for each distinct expertise, add an entry to the `agent` tool's ",
456        "`manifest`. Each entry is a worker_id → role mapping. A role is defined by:\n",
457        "   - A focused `system_prompt` that defines the specialist's role, constraints, and output format\n",
458        "   - Optional `allowed_tools` (omit or leave empty for the read-only set: ",
459        "Read, Grep, Glob, WebFetch, SearchFiles; list tool names to grant write tools)\n",
460        "   - Or a `definition` referencing a built-in role from `.recursive/agents/*.md` ",
461        "(e.g. `explore`, `plan`, `verification`, `general-purpose`)\n",
462        "3. **Dispatch** — set the `agent` tool's `mode`:\n",
463        "   - `single` — exactly one worker, runs to completion and returns its result.\n",
464        "   - `parallel` — all workers run concurrently. Use for independent read-only tasks ",
465        "(research, review).\n",
466        "   - `sequential` — workers run one after another. Use when one task's output feeds the next, ",
467        "or for write-heavy tasks that may touch the same files.\n",
468        "   - For a long-running task you want to continue later, set `background: true` with ",
469        "`mode: \"single\"`. The worker is spawned in the background and you get a `task_id` immediately ",
470        "(instead of waiting). Use `send_message(task_id=...)` to run follow-up turns on the same worker, ",
471        "and `task_output` / `task_stop` to inspect or cancel it.\n",
472        "4. **Synthesise** — collect all worker results and write a final unified answer.\n\n",
473        "## Rules\n\n",
474        "- Design the *minimum* number of specialists the task requires — avoid over-decomposition.\n",
475        "- Read-only tasks (analysis, review, research) can always run in parallel.\n",
476        "- Write-heavy tasks (coding, patching) should run sequentially unless you are certain they touch different files.\n",
477        "- After all workers finish, synthesise their outputs into a single coherent response rather than just concatenating them.\n\n",
478        "## Writing worker prompts\n\n",
479        "**Workers cannot see this conversation.** Every `prompt` you pass to the `agent` tool, ",
480        "and every `message` you send via `send_message`, must be self-contained — it is the only ",
481        "context the worker has.\n\n",
482        "### Never delegate understanding\n\n",
483        "When a worker reports research findings, **you** must understand them before directing\n",
484        "follow-up work. Read the findings, identify the approach, then write a prompt that *proves*\n",
485        "you understood it by naming the exact `file:line`, the specific change, and what \"done\" looks\n",
486        "like. Never write \"based on your findings, fix the bug\" or \"based on the research, implement\n",
487        "it\" — those phrases push the synthesis onto the worker instead of doing it yourself. You never\n",
488        "hand off understanding to another worker.\n\n",
489        "```\n",
490        "// Bad — lazy delegation (workers can't see this conversation)\n",
491        "agent({ mode: \"single\",\n",
492        "  manifest: { fixer: { system_prompt: \"You fix bugs.\" } },\n",
493        "  prompt: \"Based on your findings, fix the auth bug\" })\n",
494        "\n",
495        "// Good — synthesised spec: file:line, root cause, exact change, done-criterion\n",
496        "agent({ mode: \"single\",\n",
497        "  manifest: { coder: { system_prompt: \"You implement tasks using the available tools.\",\n",
498        "                        allowed_tools: [\"Read\", \"Edit\", \"Bash\"] } },\n",
499        "  prompt: \"Fix the null deref in src/auth/validate.rs:42. The `user` field on Session\n",
500        "    (src/auth/types.rs:15) is None when a session expires but the token stays cached.\n",
501        "    Add a None check before `user.id` access — if None, return 401 with 'Session expired'.\n",
502        "    Run `cargo test -p auth` and report the result.\" })\n",
503        "```\n\n",
504        "### What every worker prompt must contain\n\n",
505        "- **Target**: concrete `file:line` references and the exact change to make — not \"the auth\n",
506        "  module\", but `src/auth/validate.rs:42`.\n",
507        "- **Root cause, not symptom**: guide the worker toward a durable fix, not a patch that\n",
508        "  silences the error.\n",
509        "- **Done-criterion**: state what \"done\" looks like. For implementation: \"run the relevant\n",
510        "  tests and typecheck, then report the result\". For research: \"report file paths, line\n",
511        "  numbers, and signatures — do not modify files\".\n",
512        "- **Purpose**: one line on why the task matters, so the worker can calibrate depth (\"this\n",
513        "  informs a PR description — focus on user-facing changes\").\n\n",
514        "### Continue vs spawn — decide by context overlap\n\n",
515        "A background worker (`background: true`) stays alive across turns: `send_message(task_id=...)` ",
516        "runs a new turn on the same runtime, so the worker keeps its loaded files and prior findings. ",
517        "Choose between continuing an existing background worker and spawning a fresh one by how much of ",
518        "its loaded context overlaps the next task:\n\n",
519        "| Situation | Mechanism | Why |\n",
520        "|-----------|-----------|-----|\n",
521        "| Follow-up refines the background worker's current task | **Continue** (`send_message`) | Worker already has the files loaded AND now gets a clearer plan |\n",
522        "| Research was broad but the next task is narrow | **Spawn fresh** (`agent`) | Focused context is cleaner than dragging along exploration noise |\n",
523        "| Correcting a failure or extending recent work | **Continue** (`send_message`) | Worker has the error context and knows what it just tried |\n",
524        "| Verifying code a different worker just wrote | **Spawn fresh** (`agent`) | A verifier should see the code with fresh eyes, not carry the implementer's assumptions |\n",
525        "| First attempt used the wrong approach entirely | **Spawn fresh** | Wrong-approach context pollutes the retry; clean slate avoids anchoring on the failed path |\n",
526        "| Unrelated task | **Spawn fresh** | No useful context to reuse |\n\n",
527        "There is no universal default. High overlap → continue with `send_message`. Low overlap → spawn fresh.\n\n",
528        "### Verification — prove it works, don't confirm it exists\n\n",
529        "When a worker (or you) claims a change is done, verification means **proving the code works**,\n",
530        "not confirming that it exists. A verifier that rubber-stamps weak work undermines everything.\n",
531        "- Run tests **with the feature exercised**, not just \"the suite passes overall\".\n",
532        "- Run typecheck/clippy and **investigate** errors — don't dismiss them as \"unrelated\".\n",
533        "- Be sceptical: if something looks off, dig in. Try edge cases and error paths, not just the\n",
534        "  happy path the implementer already ran.\n"
535    )
536}
537
538/// Register the unified `Agent` (sub-agent / team coordination) tool — plus the
539/// coordinator-side coordination tools (`send_message`, `list_workers`,
540/// `task_create` / `task_get` / `task_list` / `task_output` / `task_stop` /
541/// `task_update`) — on `tools` when `config.subagent_enabled` is true.
542///
543/// This is the single, channel-agnostic hook called by every agent-loop entry
544/// point (CLI run / loop, HTTP API, TUI) after they build their base tool
545/// registry and resolve their provider, so the `Agent` tool and the coordinator
546/// prompt injected by [`crate::system_prompt::assemble_system_prompt`] stay in
547/// sync across all surfaces. Returns `tools` unchanged when sub-agent is
548/// disabled.
549///
550/// The coordination tools share a single `TaskRegistry` and `WorkerRegistry`
551/// with the `Agent` tool, so a coordinator can dispatch a worker via `agent`
552/// and then inspect / message / cancel it. Without this wiring the coordinator
553/// prompt would advertise tools it cannot actually call.
554pub fn register_subagent_if_enabled(
555    tools: ToolRegistry,
556    config: &Config,
557    provider: Arc<dyn ChatProvider>,
558) -> ToolRegistry {
559    if !config.subagent_enabled {
560        return tools;
561    }
562    let defs = AgentDefinitions::load(&config.workspace).unwrap_or_else(|e| {
563        tracing::warn!("Failed to load agent definitions: {e}");
564        AgentDefinitions::default()
565    });
566    // A single shared registry pair so the `agent` tool and the coordinator-side
567    // task/message tools observe the same workers.
568    let task_registry = Arc::new(TaskRegistry::new());
569    let worker_registry = WorkerRegistry::new();
570    let worker_table: crate::tools::agent::WorkerTable =
571        Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
572
573    let agent = AgentTool::new(
574        &config.workspace,
575        provider,
576        tools.fork(),
577        config.subagent_max_depth,
578        0,
579        None,
580    )
581    .with_definitions(defs)
582    .with_task_registry(task_registry.clone())
583    .with_registry(worker_registry.clone())
584    .with_workers(worker_table.clone());
585
586    // Register the `Agent` tool first, then the coordination tools that the
587    // coordinator prompt teaches alongside it. Each is built from the same
588    // shared registries. The task_* family is gated behind the
589    // `coordinator-mode` feature; when it is disabled only `agent` +
590    // `send_message` + `list_workers` are registered.
591    let tools = tools
592        .register(Arc::new(agent))
593        .register(Arc::new(SendMessageTool::new(
594            worker_registry.clone(),
595            task_registry.clone(),
596            worker_table.clone(),
597        )))
598        .register(Arc::new(ListWorkersTool::new(
599            worker_registry.clone(),
600            task_registry.clone(),
601        )));
602
603    #[cfg(feature = "coordinator-mode")]
604    {
605        tools
606            .register(Arc::new(TaskCreateTool::new(task_registry.clone())))
607            .register(Arc::new(TaskGetTool::new(task_registry.clone())))
608            .register(Arc::new(TaskListTool::new(task_registry.clone())))
609            .register(Arc::new(TaskOutputTool::new(task_registry.clone())))
610            .register(Arc::new(TaskStopTool::new(task_registry.clone())))
611            .register(Arc::new(TaskUpdateTool::new(task_registry.clone())))
612    }
613    #[cfg(not(feature = "coordinator-mode"))]
614    {
615        // Suppress unused warnings for the shared registries when the task_*
616        // tools are compiled out: send_message/list_workers above already
617        // consume `worker_registry`, and `task_registry` is still passed to
618        // the AgentTool via `with_task_registry` on the coordinator's behalf.
619        let _ = &task_registry;
620        tools
621    }
622}
623
624/// Default role set for common multi-agent patterns.
625pub fn default_roles() -> Vec<AgentRole> {
626    vec![
627        AgentRole {
628            name: "planner".into(),
629            system_prompt: "You are a planning agent. Analyze the task, break it into steps, \
630                            and output a structured plan. Do not execute — only plan."
631                .into(),
632            max_steps: 10,
633            allowed_tools: vec![],
634        },
635        AgentRole {
636            name: "coder".into(),
637            system_prompt: "You are a coding agent. Implement the task using the available \
638                            tools. Write code, run tests, fix errors."
639                .into(),
640            max_steps: 50,
641            allowed_tools: vec![],
642        },
643        AgentRole {
644            name: "reviewer".into(),
645            system_prompt: "You are a code review agent. Read the code changes, identify \
646                            issues, suggest improvements. Do not modify files."
647                .into(),
648            max_steps: 20,
649            allowed_tools: vec!["Read".into(), "Grep".into()],
650        },
651    ]
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::llm::{Completion, MockProvider};
658    use std::path::PathBuf;
659
660    fn test_config() -> Config {
661        Config {
662            workspace: PathBuf::from("."),
663            api_base: String::new(),
664            api_key: None,
665            model: String::new(),
666            provider_type: "openai".into(),
667            preset: None,
668            max_steps: 32,
669            max_tokens: 65536,
670            temperature: 0.2,
671            system_prompt: String::new(),
672            retry_max: 2,
673            retry_initial_backoff_secs: 1,
674            retry_max_backoff_secs: 8,
675            shell_timeout_secs: 300,
676            headless: false,
677            memory_summary_limit: 5,
678            thinking_budget: None,
679            session_name: None,
680            max_budget_usd: None,
681            extra_dirs: Vec::new(),
682            extra_readonly_dirs: Vec::new(),
683            allow_tools: Vec::new(),
684            context_window_override: None,
685            subagent_max_depth: 2,
686            subagent_enabled: false,
687            allow_bypass_permissions: false,
688            max_search_rounds: 3,
689            stuck_window: 10,
690            stuck_error_rate: 0.8,
691            max_concurrent_runs: 8,
692            goal_eval_transcript_tail: 12,
693            web_search_provider: None,
694            web_search_api_key: None,
695            web_search_jina_key: None,
696            wall_timeout_secs: 0,
697        }
698    }
699
700    #[tokio::test]
701    async fn shared_memory_assigns_monotonic_seq_across_writes() {
702        let mem = SharedMemory::new();
703        mem.set("a".into(), "1".into(), "alpha".into()).await;
704        mem.set("b".into(), "2".into(), "alpha".into()).await;
705        let a = mem.get("a").await.unwrap();
706        let b = mem.get("b").await.unwrap();
707        assert!(a.seq > 0, "first write should not have seq 0");
708        assert!(b.seq > a.seq, "second write seq must exceed first");
709    }
710
711    #[tokio::test]
712    async fn shared_memory_seq_advances_on_overwrite() {
713        let mem = SharedMemory::new();
714        mem.set("k".into(), "v1".into(), "alpha".into()).await;
715        let v1 = mem.get("k").await.unwrap().seq;
716        mem.set("k".into(), "v2".into(), "beta".into()).await;
717        let v2 = mem.get("k").await.unwrap().seq;
718        assert!(v2 > v1, "overwriting the same key must advance seq");
719    }
720
721    #[test]
722    fn memory_entry_deserializes_without_seq_field() {
723        // Old serialised entries (pre-seq) must round-trip to seq: 0.
724        let json = r#"{"key":"k","value":"v","author":"a","timestamp":123}"#;
725        let entry: MemoryEntry = serde_json::from_str(json).expect("deserialize legacy entry");
726        assert_eq!(entry.seq, 0);
727    }
728
729    #[test]
730    fn memory_entry_round_trips_with_seq() {
731        let entry = MemoryEntry {
732            key: "k".into(),
733            value: "v".into(),
734            author: "a".into(),
735            timestamp: 123,
736            seq: 42,
737        };
738        let json = serde_json::to_string(&entry).expect("serialize");
739        let back: MemoryEntry = serde_json::from_str(&json).expect("deserialize");
740        assert_eq!(back.seq, 42);
741    }
742
743    #[test]
744    fn new_pool_is_empty() {
745        let provider = Arc::new(MockProvider::new(vec![]));
746        let pool = AgentPool::new(provider, test_config());
747        assert_eq!(pool.role_count(), 0);
748    }
749
750    #[test]
751    fn add_role_and_get_role() {
752        let provider = Arc::new(MockProvider::new(vec![]));
753        let mut pool = AgentPool::new(provider, test_config());
754
755        let role = AgentRole {
756            name: "tester".into(),
757            system_prompt: "You test things.".into(),
758            max_steps: 5,
759            allowed_tools: vec!["Bash".into()],
760        };
761        pool.add_role(role.clone());
762
763        let retrieved = pool.get_role("tester").unwrap();
764        assert_eq!(retrieved.name, "tester");
765        assert_eq!(retrieved.system_prompt, "You test things.");
766        assert_eq!(retrieved.max_steps, 5);
767        assert_eq!(retrieved.allowed_tools, vec!["Bash"]);
768    }
769
770    #[test]
771    fn role_names_returns_all_registered() {
772        let provider = Arc::new(MockProvider::new(vec![]));
773        let mut pool = AgentPool::new(provider, test_config());
774
775        pool.add_role(AgentRole {
776            name: "alpha".into(),
777            system_prompt: "A".into(),
778            max_steps: 1,
779            allowed_tools: vec![],
780        });
781        pool.add_role(AgentRole {
782            name: "beta".into(),
783            system_prompt: "B".into(),
784            max_steps: 2,
785            allowed_tools: vec![],
786        });
787
788        let mut names = pool.role_names();
789        names.sort();
790        assert_eq!(names, vec!["alpha", "beta"]);
791        assert_eq!(pool.role_count(), 2);
792    }
793
794    #[tokio::test]
795    async fn run_with_unknown_role_returns_error() {
796        let provider = Arc::new(MockProvider::new(vec![]));
797        let pool = AgentPool::new(provider, test_config());
798
799        let result = pool.run_with_role("nonexistent", "do something").await;
800        assert!(result.is_err());
801        let err = result.unwrap_err();
802        assert!(err.to_string().contains("unknown role"));
803    }
804
805    #[tokio::test]
806    async fn run_with_role_succeeds_with_mock() {
807        let provider = Arc::new(MockProvider::new(vec![Completion {
808            content: "Plan: step 1, step 2, step 3".into(),
809            tool_calls: vec![],
810            finish_reason: Some("stop".into()),
811            usage: None,
812            reasoning_content: None,
813        }]));
814
815        let mut pool = AgentPool::new(provider, test_config());
816        pool.add_role(AgentRole {
817            name: "planner".into(),
818            system_prompt: "You are a planner.".into(),
819            max_steps: 5,
820            allowed_tools: vec![],
821        });
822
823        let outcome = pool.run_with_role("planner", "plan a task").await.unwrap();
824        assert_eq!(
825            outcome.finish_reason,
826            crate::agent::FinishReason::NoMoreToolCalls
827        );
828        assert!(outcome.final_text.unwrap().contains("Plan:"));
829    }
830
831    #[test]
832    fn default_roles_returns_three_roles() {
833        let roles = default_roles();
834        assert_eq!(roles.len(), 3);
835
836        let names: Vec<&str> = roles.iter().map(|r| r.name.as_str()).collect();
837        assert!(names.contains(&"planner"));
838        assert!(names.contains(&"coder"));
839        assert!(names.contains(&"reviewer"));
840    }
841
842    #[tokio::test]
843    async fn shared_memory_set_and_get() {
844        let mem = SharedMemory::new();
845        mem.set("goal".into(), "build feature X".into(), "planner".into())
846            .await;
847
848        let entry = mem.get("goal").await.unwrap();
849        assert_eq!(entry.key, "goal");
850        assert_eq!(entry.value, "build feature X");
851        assert_eq!(entry.author, "planner");
852        assert!(entry.timestamp > 0);
853    }
854
855    #[tokio::test]
856    async fn shared_memory_keys() {
857        let mem = SharedMemory::new();
858        mem.set("a".into(), "1".into(), "agent1".into()).await;
859        mem.set("b".into(), "2".into(), "agent2".into()).await;
860
861        let mut keys = mem.keys().await;
862        keys.sort();
863        assert_eq!(keys, vec!["a", "b"]);
864        assert_eq!(mem.len().await, 2);
865        assert!(!mem.is_empty().await);
866    }
867
868    #[tokio::test]
869    async fn shared_memory_remove() {
870        let mem = SharedMemory::new();
871        mem.set("tmp".into(), "val".into(), "x".into()).await;
872        assert!(mem.get("tmp").await.is_some());
873
874        let removed = mem.remove("tmp").await;
875        assert!(removed);
876        assert!(mem.get("tmp").await.is_none());
877
878        // Removing non-existent key returns false
879        let removed_again = mem.remove("tmp").await;
880        assert!(!removed_again);
881    }
882
883    #[tokio::test]
884    async fn shared_memory_to_context_string() {
885        let mem = SharedMemory::new();
886        mem.set("status".into(), "in-progress".into(), "coder".into())
887            .await;
888
889        let ctx = mem.to_context_string().await;
890        assert!(ctx.contains("[Shared Memory]"));
891        assert!(ctx.contains("status = in-progress (by coder)"));
892    }
893
894    #[tokio::test]
895    async fn shared_memory_empty_context_returns_empty() {
896        let mem = SharedMemory::new();
897        let ctx = mem.to_context_string().await;
898        assert!(ctx.is_empty());
899    }
900
901    #[tokio::test]
902    async fn agent_pool_includes_memory_context() {
903        let provider = Arc::new(MockProvider::new(vec![Completion {
904            content: "I see the shared memory context.".into(),
905            tool_calls: vec![],
906            finish_reason: Some("stop".into()),
907            usage: None,
908            reasoning_content: None,
909        }]));
910
911        let mut pool = AgentPool::new(provider, test_config());
912        pool.add_role(AgentRole {
913            name: "worker".into(),
914            system_prompt: "You are a worker.".into(),
915            max_steps: 5,
916            allowed_tools: vec![],
917        });
918
919        // Set memory before running
920        pool.memory()
921            .set("plan".into(), "step 1 done".into(), "planner".into())
922            .await;
923
924        let outcome = pool.run_with_role("worker", "continue work").await.unwrap();
925        assert_eq!(
926            outcome.finish_reason,
927            crate::agent::FinishReason::NoMoreToolCalls
928        );
929        // The run succeeded with memory context injected — no error means integration works
930        assert!(outcome.final_text.is_some());
931    }
932
933    // --- MessageBus tests ---
934
935    fn make_msg(from: &str, to: &str, content: &str, msg_type: MessageType) -> AgentMessage {
936        AgentMessage {
937            id: generate_message_id(),
938            from: from.to_string(),
939            to: to.to_string(),
940            content: content.to_string(),
941            msg_type,
942            timestamp: now_timestamp(),
943        }
944    }
945
946    #[tokio::test]
947    async fn message_bus_send_and_inbox() {
948        let bus = MessageBus::new();
949        let msg = make_msg("planner", "coder", "implement feature X", MessageType::Task);
950        bus.send(msg).await;
951
952        let inbox = bus.inbox("coder").await;
953        assert_eq!(inbox.len(), 1);
954        assert_eq!(inbox[0].content, "implement feature X");
955        assert_eq!(inbox[0].from, "planner");
956        assert_eq!(inbox[0].msg_type, MessageType::Task);
957
958        // Other roles see empty inbox
959        let empty = bus.inbox("reviewer").await;
960        assert!(empty.is_empty());
961    }
962
963    #[tokio::test]
964    async fn message_bus_outbox() {
965        let bus = MessageBus::new();
966        bus.send(make_msg(
967            "coder",
968            "reviewer",
969            "done coding",
970            MessageType::Result,
971        ))
972        .await;
973        bus.send(make_msg(
974            "coder",
975            "planner",
976            "need clarification",
977            MessageType::Question,
978        ))
979        .await;
980        bus.send(make_msg(
981            "planner",
982            "coder",
983            "here is the plan",
984            MessageType::Task,
985        ))
986        .await;
987
988        let outbox = bus.outbox("coder").await;
989        assert_eq!(outbox.len(), 2);
990        assert!(outbox.iter().all(|m| m.from == "coder"));
991
992        let planner_outbox = bus.outbox("planner").await;
993        assert_eq!(planner_outbox.len(), 1);
994    }
995
996    #[tokio::test]
997    async fn message_bus_broadcast_reaches_all() {
998        let bus = MessageBus::new();
999        bus.send(make_msg(
1000            "admin",
1001            "broadcast",
1002            "system update",
1003            MessageType::Broadcast,
1004        ))
1005        .await;
1006
1007        let coder_inbox = bus.inbox("coder").await;
1008        let reviewer_inbox = bus.inbox("reviewer").await;
1009        let planner_inbox = bus.inbox("planner").await;
1010
1011        assert_eq!(coder_inbox.len(), 1);
1012        assert_eq!(reviewer_inbox.len(), 1);
1013        assert_eq!(planner_inbox.len(), 1);
1014        assert_eq!(coder_inbox[0].content, "system update");
1015    }
1016
1017    #[tokio::test]
1018    async fn message_bus_subscribe_receives() {
1019        let bus = MessageBus::new();
1020        let mut rx = bus.subscribe("coder").await;
1021
1022        // Send after subscribing
1023        let msg = make_msg("planner", "coder", "task for you", MessageType::Task);
1024        bus.send(msg).await;
1025
1026        let received = rx.recv().await.unwrap();
1027        assert_eq!(received.content, "task for you");
1028        assert_eq!(received.from, "planner");
1029    }
1030
1031    #[tokio::test]
1032    async fn message_bus_history() {
1033        let bus = MessageBus::new();
1034        bus.send(make_msg("a", "b", "msg1", MessageType::Task))
1035            .await;
1036        bus.send(make_msg("b", "a", "msg2", MessageType::Result))
1037            .await;
1038        bus.send(make_msg("a", "broadcast", "msg3", MessageType::Broadcast))
1039            .await;
1040
1041        let history = bus.history().await;
1042        assert_eq!(history.len(), 3);
1043        assert_eq!(history[0].content, "msg1");
1044        assert_eq!(history[1].content, "msg2");
1045        assert_eq!(history[2].content, "msg3");
1046    }
1047
1048    #[tokio::test]
1049    async fn message_bus_clear() {
1050        let bus = MessageBus::new();
1051        bus.send(make_msg("a", "b", "hello", MessageType::Task))
1052            .await;
1053        assert_eq!(bus.history().await.len(), 1);
1054
1055        bus.clear().await;
1056        assert!(bus.history().await.is_empty());
1057        assert!(bus.inbox("b").await.is_empty());
1058    }
1059
1060    #[tokio::test]
1061    async fn agent_pool_send_task_convenience() {
1062        let provider = Arc::new(MockProvider::new(vec![]));
1063        let pool = AgentPool::new(provider, test_config());
1064
1065        pool.send_task("planner", "coder", "build module Y").await;
1066        pool.send_result("coder", "planner", "module Y complete")
1067            .await;
1068
1069        let inbox = pool.bus().inbox("coder").await;
1070        assert_eq!(inbox.len(), 1);
1071        assert_eq!(inbox[0].content, "build module Y");
1072        assert_eq!(inbox[0].msg_type, MessageType::Task);
1073
1074        let planner_inbox = pool.bus().inbox("planner").await;
1075        assert_eq!(planner_inbox.len(), 1);
1076        assert_eq!(planner_inbox[0].content, "module Y complete");
1077        assert_eq!(planner_inbox[0].msg_type, MessageType::Result);
1078
1079        let history = pool.bus().history().await;
1080        assert_eq!(history.len(), 2);
1081    }
1082
1083    #[tokio::test]
1084    async fn message_bus_evicts_oldest_on_overflow() {
1085        let bus = MessageBus::with_capacity(3);
1086        for i in 0..5 {
1087            bus.send(make_msg(
1088                &format!("a{i}"),
1089                "broadcast",
1090                &format!("msg-{i}"),
1091                MessageType::Feedback,
1092            ))
1093            .await;
1094        }
1095        let history = bus.history().await;
1096        let contents: Vec<_> = history.iter().map(|m| m.content.clone()).collect();
1097        // msg-0 and msg-1 evicted; msg-2,3,4 are the most recent 3
1098        assert_eq!(contents, vec!["msg-2", "msg-3", "msg-4"]);
1099        assert_eq!(history.len(), 3);
1100    }
1101
1102    #[tokio::test]
1103    async fn message_bus_default_capacity_is_bounded() {
1104        let bus = MessageBus::new();
1105        assert_eq!(MESSAGE_BUS_CAPACITY, 1000);
1106        // Verify the bus uses this capacity: 10 messages should all be retained
1107        for i in 0..10 {
1108            bus.send(make_msg("a", "b", &format!("m{i}"), MessageType::Task))
1109                .await;
1110        }
1111        assert_eq!(bus.history().await.len(), 10);
1112    }
1113
1114    // --- SharedMemory::all ---
1115
1116    #[tokio::test]
1117    async fn shared_memory_all_returns_all_entries() {
1118        let mem = SharedMemory::new();
1119        mem.set("x".into(), "1".into(), "a".into()).await;
1120        mem.set("y".into(), "2".into(), "b".into()).await;
1121        let all = mem.all().await;
1122        assert_eq!(all.len(), 2, "all() must return every stored entry");
1123        let mut keys: Vec<String> = all.iter().map(|e| e.key.clone()).collect();
1124        keys.sort();
1125        assert_eq!(keys, vec!["x", "y"]);
1126    }
1127
1128    #[tokio::test]
1129    async fn shared_memory_all_empty_returns_empty_vec() {
1130        let mem = SharedMemory::new();
1131        assert!(
1132            mem.all().await.is_empty(),
1133            "all() on empty store must return empty vec"
1134        );
1135    }
1136
1137    // --- AgentMode::parse ---
1138
1139    #[test]
1140    fn agent_mode_parse_single() {
1141        assert_eq!(AgentMode::parse("single"), Some(AgentMode::Single));
1142    }
1143
1144    #[test]
1145    fn agent_mode_parse_parallel() {
1146        assert_eq!(AgentMode::parse("parallel"), Some(AgentMode::Parallel));
1147    }
1148
1149    #[test]
1150    fn agent_mode_parse_sequential() {
1151        assert_eq!(AgentMode::parse("sequential"), Some(AgentMode::Sequential));
1152    }
1153
1154    #[test]
1155    fn agent_mode_parse_unknown_returns_none() {
1156        assert_eq!(AgentMode::parse(""), None);
1157        assert_eq!(AgentMode::parse("xyzzy"), None);
1158    }
1159
1160    // --- AgentPool::remove_role ---
1161
1162    #[test]
1163    fn agent_pool_remove_role_returns_true_when_present() {
1164        let provider = Arc::new(MockProvider::new(vec![]));
1165        let mut pool = AgentPool::new(provider, test_config());
1166        pool.add_role(AgentRole {
1167            name: "tmp".into(),
1168            system_prompt: "X".into(),
1169            max_steps: 1,
1170            allowed_tools: vec![],
1171        });
1172        assert!(
1173            pool.remove_role("tmp"),
1174            "remove existing role must return true"
1175        );
1176        assert_eq!(pool.role_count(), 0);
1177    }
1178
1179    #[test]
1180    fn agent_pool_remove_role_returns_false_when_absent() {
1181        let provider = Arc::new(MockProvider::new(vec![]));
1182        let mut pool = AgentPool::new(provider, test_config());
1183        assert!(
1184            !pool.remove_role("nonexistent"),
1185            "remove absent role must return false"
1186        );
1187    }
1188
1189    // --- coordinator_system_prompt ---
1190
1191    #[test]
1192    fn coordinator_system_prompt_is_nonempty_and_not_placeholder() {
1193        let prompt = coordinator_system_prompt();
1194        assert!(!prompt.is_empty(), "coordinator prompt must not be empty");
1195        assert_ne!(
1196            prompt, "xyzzy",
1197            "coordinator prompt must not be xyzzy placeholder"
1198        );
1199        assert!(
1200            prompt.contains("coordinator"),
1201            "coordinator prompt must mention 'coordinator'"
1202        );
1203    }
1204
1205    #[test]
1206    fn coordinator_system_prompt_teaches_worker_briefing() {
1207        // P1 (design doc 5.4/5.5): the coordinator must not just say *how* to
1208        // dispatch — it must teach *how to write the brief*. These phrases are
1209        // the load-bearing ideas; pin them so a future trim doesn't silently
1210        // drop the methodology.
1211        let prompt = coordinator_system_prompt();
1212        assert!(
1213            prompt.contains("Writing worker prompts"),
1214            "coordinator prompt must have a 'Writing worker prompts' section"
1215        );
1216        assert!(
1217            prompt.contains("Never delegate understanding"),
1218            "coordinator prompt must forbid delegating understanding to workers"
1219        );
1220        // Self-containment: workers can't see the conversation.
1221        assert!(
1222            prompt.contains("cannot see this conversation"),
1223            "coordinator prompt must state workers cannot see the conversation"
1224        );
1225        // Continue vs spawn decision table — both mechanisms must be named.
1226        assert!(
1227            prompt.contains("send_message") && prompt.contains("agent"),
1228            "coordinator prompt must cover both continue (send_message) and spawn (agent)"
1229        );
1230        // Verification bar: prove it works, don't confirm it exists.
1231        assert!(
1232            prompt.contains("prove"),
1233            "coordinator prompt must raise the verification bar beyond 'it exists'"
1234        );
1235    }
1236
1237    // --- default_roles content ---
1238
1239    #[test]
1240    fn default_roles_have_expected_steps_and_tools() {
1241        let roles = default_roles();
1242        assert!(!roles.is_empty(), "default_roles must return non-empty vec");
1243
1244        let planner = roles
1245            .iter()
1246            .find(|r| r.name == "planner")
1247            .expect("planner role");
1248        let coder = roles
1249            .iter()
1250            .find(|r| r.name == "coder")
1251            .expect("coder role");
1252        let reviewer = roles
1253            .iter()
1254            .find(|r| r.name == "reviewer")
1255            .expect("reviewer role");
1256
1257        // Each role must have a positive step limit.
1258        assert!(planner.max_steps > 0);
1259        assert!(coder.max_steps > 0);
1260        assert!(reviewer.max_steps > 0);
1261
1262        // Reviewer is read-only so it must declare some allowed tools.
1263        assert!(
1264            !reviewer.allowed_tools.is_empty(),
1265            "reviewer must have allowed_tools"
1266        );
1267
1268        // Prompts must be non-empty.
1269        assert!(!planner.system_prompt.is_empty());
1270        assert!(!coder.system_prompt.is_empty());
1271        assert!(!reviewer.system_prompt.is_empty());
1272    }
1273
1274    // --- register_subagent_if_enabled: disabled path ---
1275
1276    #[test]
1277    fn register_subagent_if_enabled_noop_when_disabled() {
1278        let provider = Arc::new(MockProvider::new(vec![]));
1279        let config = test_config(); // subagent_enabled: false
1280        let tools = crate::tools::ToolRegistry::local();
1281        let initial_names = tools.names();
1282        let result = register_subagent_if_enabled(tools, &config, provider);
1283        assert_eq!(
1284            result.names(),
1285            initial_names,
1286            "disabled subagent must not register any additional tools"
1287        );
1288    }
1289}