Skip to main content

heartbit_core/agent/
orchestrator.rs

1//! Multi-agent orchestrator for parallel and sequential sub-agent delegation.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use tracing::{info, info_span};
11
12use crate::error::Error;
13use crate::llm::types::{TokenUsage, ToolDefinition};
14use crate::llm::{BoxedProvider, LlmProvider};
15use crate::tool::{Tool, ToolOutput};
16use crate::types::DispatchMode;
17
18use crate::memory::Memory;
19
20use crate::knowledge::KnowledgeBase;
21
22use crate::tool::builtins::OnQuestion;
23
24use super::blackboard::{Blackboard, InMemoryBlackboard};
25use super::blackboard_tools::blackboard_tools;
26use super::context::ContextStrategy;
27use super::events::{AgentEvent, OnEvent};
28use super::guardrail::Guardrail;
29use super::{AgentOutput, AgentRunner};
30
31/// A sub-agent definition registered with the orchestrator.
32#[derive(Clone)]
33pub(crate) struct SubAgentDef {
34    pub(crate) name: String,
35    pub(crate) description: String,
36    pub(crate) system_prompt: String,
37    pub(crate) tools: Vec<Arc<dyn Tool>>,
38    pub(crate) context_strategy: Option<ContextStrategy>,
39    pub(crate) summarize_threshold: Option<u32>,
40    pub(crate) tool_timeout: Option<Duration>,
41    pub(crate) max_tool_output_bytes: Option<usize>,
42    /// Per-agent turn limit. When `None`, uses orchestrator default.
43    pub(crate) max_turns: Option<usize>,
44    /// Per-agent token limit. When `None`, uses orchestrator default.
45    pub(crate) max_tokens: Option<u32>,
46    /// Optional JSON Schema for structured output.
47    pub(crate) response_schema: Option<serde_json::Value>,
48    /// Guardrails applied to this sub-agent's LLM calls and tool executions.
49    pub(crate) guardrails: Vec<Arc<dyn Guardrail>>,
50    /// Optional per-agent run timeout. When `None`, no timeout is applied.
51    pub(crate) run_timeout: Option<Duration>,
52    /// Optional per-agent LLM provider override. When `None`, the orchestrator's
53    /// shared provider is used.
54    pub(crate) provider_override: Option<Arc<BoxedProvider>>,
55    /// Optional reasoning/thinking effort level for this sub-agent.
56    pub(crate) reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
57    /// Enable reflection prompts after tool results for this sub-agent.
58    pub(crate) enable_reflection: Option<bool>,
59    /// Tool output compression threshold in bytes for this sub-agent.
60    pub(crate) tool_output_compression_threshold: Option<usize>,
61    /// Maximum tools per turn for this sub-agent.
62    pub(crate) max_tools_per_turn: Option<usize>,
63    /// Tool profile for pre-filtering tool definitions.
64    pub(crate) tool_profile: Option<super::tool_filter::ToolProfile>,
65    /// Maximum consecutive identical tool-call turns for doom loop detection.
66    pub(crate) max_identical_tool_calls: Option<u32>,
67    /// Maximum consecutive fuzzy-identical tool-call turns for doom loop detection.
68    pub(crate) max_fuzzy_identical_tool_calls: Option<u32>,
69    /// Maximum number of tool calls allowed in a single LLM turn (per-turn cap).
70    pub(crate) max_tool_calls_per_turn: Option<u32>,
71    /// Session pruning configuration.
72    pub(crate) session_prune_config: Option<crate::agent::pruner::SessionPruneConfig>,
73    /// Enable recursive summarization.
74    pub(crate) enable_recursive_summarization: Option<bool>,
75    /// Memory reflection threshold.
76    pub(crate) reflection_threshold: Option<u32>,
77    /// Run memory consolidation at session end.
78    pub(crate) consolidate_on_exit: Option<bool>,
79    /// Optional workspace root for this sub-agent.
80    pub(crate) workspace: Option<std::path::PathBuf>,
81    /// Hard limit on cumulative tokens (input + output) across all turns.
82    pub(crate) max_total_tokens: Option<u64>,
83    /// Optional audit trail for recording untruncated agent decisions.
84    pub(crate) audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
85    /// Optional user ID for multi-tenant audit enrichment.
86    pub(crate) audit_user_id: Option<String>,
87    /// Optional tenant ID for multi-tenant audit enrichment.
88    pub(crate) audit_tenant_id: Option<String>,
89    /// Delegation chain for audit records (propagated from orchestrator).
90    pub(crate) audit_delegation_chain: Vec<String>,
91    /// Per-sub-agent context-management wiring (multi-agent enablement).
92    pub(crate) context: SubAgentContextConfig,
93}
94
95impl std::fmt::Debug for SubAgentDef {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("SubAgentDef")
98            .field("name", &self.name)
99            .field("description", &self.description)
100            .field("tools_count", &self.tools.len())
101            .finish()
102    }
103}
104
105impl SubAgentDef {
106    /// Create a new sub-agent definition with required fields. All optional
107    /// fields default to `None`, empty vecs, or false.
108    pub(crate) fn new(
109        name: impl Into<String>,
110        description: impl Into<String>,
111        system_prompt: impl Into<String>,
112    ) -> Self {
113        Self {
114            name: name.into(),
115            description: description.into(),
116            system_prompt: system_prompt.into(),
117            tools: vec![],
118            context_strategy: None,
119            summarize_threshold: None,
120            tool_timeout: None,
121            max_tool_output_bytes: None,
122            max_turns: None,
123            max_tokens: None,
124            response_schema: None,
125            run_timeout: None,
126            guardrails: vec![],
127            provider_override: None,
128            reasoning_effort: None,
129            enable_reflection: None,
130            tool_output_compression_threshold: None,
131            max_tools_per_turn: None,
132            tool_profile: None,
133            max_identical_tool_calls: None,
134            max_fuzzy_identical_tool_calls: None,
135            max_tool_calls_per_turn: None,
136            session_prune_config: None,
137            enable_recursive_summarization: None,
138            reflection_threshold: None,
139            consolidate_on_exit: None,
140            workspace: None,
141            max_total_tokens: None,
142            audit_trail: None,
143            audit_user_id: None,
144            audit_tenant_id: None,
145            audit_delegation_chain: Vec::new(),
146            context: SubAgentContextConfig::default(),
147        }
148    }
149}
150
151impl From<SubAgentConfig> for SubAgentDef {
152    fn from(def: SubAgentConfig) -> Self {
153        Self {
154            name: def.name,
155            description: def.description,
156            system_prompt: def.system_prompt,
157            tools: def.tools,
158            context_strategy: def.context_strategy,
159            summarize_threshold: def.summarize_threshold,
160            tool_timeout: def.tool_timeout,
161            max_tool_output_bytes: def.max_tool_output_bytes,
162            max_turns: def.max_turns,
163            max_tokens: def.max_tokens,
164            response_schema: def.response_schema,
165            run_timeout: def.run_timeout,
166            guardrails: def.guardrails,
167            provider_override: def.provider,
168            reasoning_effort: def.reasoning_effort,
169            enable_reflection: def.enable_reflection,
170            tool_output_compression_threshold: def.tool_output_compression_threshold,
171            max_tools_per_turn: def.max_tools_per_turn,
172            tool_profile: def.tool_profile,
173            max_identical_tool_calls: def.max_identical_tool_calls,
174            max_fuzzy_identical_tool_calls: def.max_fuzzy_identical_tool_calls,
175            max_tool_calls_per_turn: def.max_tool_calls_per_turn,
176            session_prune_config: def.session_prune_config,
177            enable_recursive_summarization: def.enable_recursive_summarization,
178            reflection_threshold: def.reflection_threshold,
179            consolidate_on_exit: def.consolidate_on_exit,
180            workspace: def.workspace,
181            max_total_tokens: def.max_total_tokens,
182            audit_trail: def.audit_trail,
183            audit_user_id: def.audit_user_id,
184            audit_tenant_id: def.audit_tenant_id,
185            audit_delegation_chain: def.audit_delegation_chain,
186            context: def.context,
187        }
188    }
189}
190
191/// A task delegated by the orchestrator to a sub-agent.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub(crate) struct DelegatedTask {
194    pub(crate) agent: String,
195    pub(crate) task: String,
196}
197
198/// Result from a sub-agent execution.
199#[derive(Debug, Clone)]
200pub(crate) struct SubAgentResult {
201    pub(crate) agent: String,
202    pub(crate) result: String,
203    pub(crate) tokens_used: TokenUsage,
204    pub(crate) success: bool,
205}
206
207/// Multi-agent orchestrator.
208///
209/// Refactored to use `AgentRunner` internally with a `DelegateTaskTool`.
210/// No duplicated agent loop — the orchestrator IS an AgentRunner.
211///
212/// The `DelegateTaskTool` accumulates sub-agent token usage in a shared
213/// `Arc<Mutex<TokenUsage>>`. Each `run()` call resets the accumulator
214/// before starting, so sequential calls are safe. For concurrent use,
215/// create separate `Orchestrator` instances.
216pub struct Orchestrator<P: LlmProvider> {
217    runner: AgentRunner<P>,
218    /// Shared accumulator for sub-agent token usage (populated by DelegateTaskTool).
219    sub_agent_tokens: Arc<Mutex<TokenUsage>>,
220}
221
222impl<P: LlmProvider + 'static> Orchestrator<P> {
223    /// Create a new [`OrchestratorBuilder`] with the given LLM provider.
224    pub fn builder(provider: Arc<P>) -> OrchestratorBuilder<P> {
225        OrchestratorBuilder {
226            provider,
227            sub_agents: vec![],
228            max_turns: 10,
229            max_tokens: 4096,
230            context_strategy: None,
231            summarize_threshold: None,
232            tool_timeout: None,
233            max_tool_output_bytes: None,
234            shared_memory: None,
235            memory_namespace_prefix: None,
236            blackboard: None,
237            knowledge_base: None,
238            on_text: None,
239            on_reasoning: None,
240            on_approval: None,
241            on_input: None,
242            interrupt: None,
243            on_event: None,
244            guardrails: Vec::new(),
245            on_question: None,
246            run_timeout: None,
247            enable_squads: None,
248            reasoning_effort: None,
249            enable_reflection: false,
250            tool_output_compression_threshold: None,
251            max_tools_per_turn: None,
252            max_identical_tool_calls: None,
253            max_fuzzy_identical_tool_calls: None,
254            max_tool_calls_per_turn: None,
255            permission_rules: super::permission::PermissionRuleset::default(),
256            instruction_text: None,
257            learned_permissions: None,
258            lsp_manager: None,
259            observability_mode: None,
260            dispatch_mode: DispatchMode::Parallel,
261            workspace: None,
262            audit_trail: None,
263            audit_user_id: None,
264            audit_tenant_id: None,
265            audit_delegation_chain: Vec::new(),
266            allow_shared_write: true,
267            multi_agent_prompt: true,
268            spawn_config: None,
269            spawn_builtin_tools: Vec::new(),
270            tenant_tracker: None,
271            entry_mode: false,
272            entry_direct_tools: Vec::new(),
273            entry_workflow_recipes: Vec::new(),
274            entry_context: SubAgentContextConfig::default(),
275            entry_goal: None,
276            entry_goal_judge: None,
277            entry_request_router: None,
278        }
279    }
280
281    /// Run the orchestrator with a task. Returns the combined output from
282    /// the orchestrator and all sub-agents.
283    ///
284    /// # Concurrent use
285    ///
286    /// This method takes `&mut self` to prevent concurrent calls on the same
287    /// instance at compile time. The sub-agent token accumulator is reset at
288    /// the start of each call, so concurrent runs would produce incorrect
289    /// token counts. For concurrent use, create separate `Orchestrator`
290    /// instances.
291    pub async fn run(&mut self, task: &str) -> Result<AgentOutput, Error> {
292        // Reset sub-agent token accumulator so repeated calls don't inflate counts
293        {
294            let mut acc = self.sub_agent_tokens.lock().expect("token lock poisoned");
295            *acc = TokenUsage::default();
296        }
297        match self.runner.execute(task).await {
298            Ok(mut output) => {
299                // Add sub-agent tokens that were accumulated during delegation
300                let sub_tokens = *self.sub_agent_tokens.lock().expect("token lock poisoned");
301                output.tokens_used += sub_tokens;
302                Ok(output)
303            }
304            Err(e) => {
305                // Include sub-agent tokens in the error's partial usage so callers
306                // see the full token cost even when the orchestrator itself fails.
307                let sub_tokens = *self.sub_agent_tokens.lock().expect("token lock poisoned");
308                let mut usage = e.partial_usage();
309                usage += sub_tokens;
310                Err(e.with_partial_usage(usage))
311            }
312        }
313    }
314}
315
316/// The orchestrator's primary tool: delegates tasks to sub-agents in parallel.
317///
318/// Implements `Tool` so it can be registered with `AgentRunner`.
319/// Unknown agent names return an error result to the LLM instead of crashing.
320///
321/// Sub-agents always use `AgentRunner<BoxedProvider>` for type erasure. Each
322/// sub-agent uses its `provider_override` if set, falling back to `shared_provider`.
323struct DelegateTaskTool {
324    shared_provider: Arc<BoxedProvider>,
325    sub_agents: Vec<SubAgentDef>,
326    max_turns: usize,
327    max_tokens: u32,
328    /// Permission rules inherited from the orchestrator, forwarded to sub-agents.
329    permission_rules: super::permission::PermissionRuleset,
330    /// Shared accumulator for sub-agent token usage, read by Orchestrator::run.
331    accumulated_tokens: Arc<Mutex<TokenUsage>>,
332    /// Shared memory store for cross-agent memory (None if not configured).
333    shared_memory: Option<Arc<dyn Memory>>,
334    /// Optional prefix prepended to agent names when namespacing memory
335    /// (e.g. `"tg:123"` → namespace becomes `"tg:123:assistant"` instead of `"assistant"`).
336    memory_namespace_prefix: Option<String>,
337    /// Shared blackboard for cross-agent coordination (None if not configured).
338    blackboard: Option<Arc<dyn Blackboard>>,
339    /// Shared knowledge base for document retrieval (None if not configured).
340    knowledge_base: Option<Arc<dyn KnowledgeBase>>,
341    /// Cached tool definition, computed at construction time to avoid calling
342    /// `Tool::definition()` on every sub-agent tool every LLM turn.
343    cached_definition: ToolDefinition,
344    /// Optional event callback for sub-agent dispatch/completion events.
345    on_event: Option<Arc<OnEvent>>,
346    /// Optional LSP manager, forwarded to sub-agents.
347    lsp_manager: Option<Arc<crate::lsp::LspManager>>,
348    /// Observability mode inherited from the orchestrator, forwarded to sub-agents.
349    observability_mode: super::observability::ObservabilityMode,
350    /// Whether sub-agents may write to shared institutional memory.
351    allow_shared_write: bool,
352    /// Optional per-tenant token tracker propagated to all sub-agent runners.
353    tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
354    /// Orchestrator-level guardrails propagated to delegated sub-agents.
355    /// SECURITY (F-AGENT-2): without this, sub-agents tournaient sans les
356    /// défenses (PII, secret scanner, LLM judge) que l'opérateur avait
357    /// configurées au niveau orchestrator.
358    guardrails: Vec<Arc<dyn Guardrail>>,
359    /// Human-approval callback propagated to delegated sub-agents.
360    /// SECURITY (audit 2026-06-09): without this, a sub-agent's tool calls hit
361    /// the runner's "Ask with no callback → allow" fallback, so one approved
362    /// `delegate_task` call silently dropped the whole human gate.
363    on_approval: Option<Arc<crate::llm::OnApproval>>,
364    /// Shared AlwaysAllow/AlwaysDeny store propagated to sub-agents so
365    /// persistent approval decisions land in ONE ruleset for the whole run
366    /// (shared semantics — the `Arc<Mutex<…>>` makes that natural).
367    learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
368    /// Orchestration nudge state: the fingerprint of the last same-agent fan-out
369    /// batch refused (refuse-once). An explicit identical retry (same fingerprint)
370    /// is allowed through; a different bad batch re-nudges. Never held across
371    /// `.await` — read+cleared synchronously before dispatch.
372    fanout_refused: std::sync::Mutex<Option<u64>>,
373}
374
375/// Threshold for the same-agent fan-out nudge: delegating this many (or more)
376/// parallel tasks to ONE agent name in a single `delegate_task` call is the
377/// anti-pattern from live finding 6a25eb4d (4× "worker" building one
378/// interdependent artifact). Catches the live 4-pile-up; spares the 2–3-part
379/// parallel work the prompt explicitly encourages. Tunable from GateFired
380/// telemetry (bump to 4 if the trace shows happy-path friction dominating).
381const SAME_AGENT_FANOUT_NUDGE_AT: usize = 3;
382
383/// If any single agent name is the target of `>= threshold` tasks in one batch,
384/// return `(name, count)` — the orchestration nudge's only robust, language- and
385/// format-agnostic signal (it reads the typed `agent` field, not the free-text
386/// `task`, which mid-tier models write in any language with no path syntax).
387/// First-seen order so the reported name is deterministic.
388fn same_agent_fanout(tasks: &[DelegatedTask], threshold: usize) -> Option<(String, usize)> {
389    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
390    for t in tasks {
391        *counts.entry(t.agent.as_str()).or_insert(0) += 1;
392    }
393    tasks
394        .iter()
395        .map(|t| t.agent.as_str())
396        .find(|name| counts[name] >= threshold)
397        .map(|name| (name.to_string(), counts[name]))
398}
399
400/// Order-sensitive fingerprint of a delegate batch, so the refuse-once gate can
401/// recognise an EXPLICIT identical retry (the user/agent re-asserting the same
402/// call) and let it through, while a DIFFERENT bad batch re-nudges (not a global
403/// latch — live finding: a session-wide AtomicBool would fire only once ever).
404fn batch_fingerprint(tasks: &[DelegatedTask]) -> u64 {
405    use std::hash::{Hash, Hasher};
406    let mut h = std::collections::hash_map::DefaultHasher::new();
407    for t in tasks {
408        t.agent.hash(&mut h);
409        t.task.hash(&mut h);
410    }
411    h.finish()
412}
413
414/// Actionable guidance for a refused same-agent fan-out. Names the TWO correct
415/// fixes (do it yourself / one sequential task) and the explicit independent
416/// escape. Deliberately does NOT suggest form_squad or distinct agents — the
417/// design panel proved both reproduce the failure (isolated agents still race;
418/// the squad blackboard shares text, not the filesystem).
419fn fanout_guidance(agent: &str, count: usize) -> String {
420    format!(
421        "You delegated {count} parallel tasks to the SAME agent '{agent}'. delegate_task \
422         runs them as ISOLATED parallel agents — none can see another's files or output. \
423         If these tasks are INTERDEPENDENT (they build one artifact, or one needs another's \
424         result — e.g. a manifest needs the directory to exist, modules need the crate), \
425         isolated parallel agents will race and thrash. Do ONE of these instead: (1) build \
426         this set YOURSELF, in order, with your own write/edit/bash tools; or (2) send it as \
427         a SINGLE delegate_task to ONE agent, describing every step in order, so one agent \
428         does them sequentially in one workspace. If these {count} tasks are genuinely \
429         INDEPENDENT (e.g. each reads a different file), re-issue the SAME call unchanged and \
430         it will run."
431    )
432}
433
434impl DelegateTaskTool {
435    async fn delegate(&self, tasks: Vec<DelegatedTask>) -> Result<String, Error> {
436        if tasks.is_empty() {
437            return Err(Error::Agent(
438                "delegate_task requires at least one task".into(),
439            ));
440        }
441        let task_count = tasks.len();
442        let agent_names: Vec<String> = tasks.iter().map(|t| t.agent.clone()).collect();
443        let _delegate_span = info_span!(
444            "heartbit.orchestrator.delegate",
445            agent_count = task_count,
446            agents = ?agent_names,
447        );
448
449        if let Some(ref cb) = self.on_event {
450            cb(AgentEvent::SubAgentsDispatched {
451                agent: "orchestrator".into(),
452                agents: agent_names.clone(),
453            });
454        }
455
456        let mut join_set = tokio::task::JoinSet::new();
457
458        for (idx, task) in tasks.into_iter().enumerate() {
459            let agent_def = match self.sub_agents.iter().find(|a| a.name == task.agent) {
460                Some(def) => def.clone(),
461                None => {
462                    // Unknown agent: we'll collect this as an error in the results
463                    let agent_name = task.agent.clone();
464                    join_set.spawn(async move {
465                        (
466                            idx,
467                            SubAgentResult {
468                                agent: agent_name.clone(),
469                                result: format!("Error: unknown agent '{agent_name}'"),
470                                tokens_used: TokenUsage::default(),
471                                success: false,
472                            },
473                        )
474                    });
475                    continue;
476                }
477            };
478
479            let provider = agent_def
480                .provider_override
481                .clone()
482                .unwrap_or_else(|| self.shared_provider.clone());
483            let max_turns = agent_def.max_turns.unwrap_or(self.max_turns);
484            let max_tokens = agent_def.max_tokens.unwrap_or(self.max_tokens);
485            let shared_memory = self.shared_memory.clone();
486            let ns_prefix = self.memory_namespace_prefix.clone();
487            let blackboard = self.blackboard.clone();
488            let knowledge_base = self.knowledge_base.clone();
489            let on_event = self.on_event.clone();
490            let lsp_manager = self.lsp_manager.clone();
491            let permission_rules = self.permission_rules.clone();
492            let observability_mode = self.observability_mode;
493            let allow_shared_write = self.allow_shared_write;
494            let tenant_tracker = self.tenant_tracker.clone();
495            // SECURITY (F-AGENT-2): propagate orchestrator guardrails to delegated
496            // sub-agents. Without this, an opérator who hardens the orchestrator
497            // (PII, secret scanner, LLM judge) sees their defenses silently drop
498            // the moment work is delegated. SpawnAgentTool already does this; the
499            // delegate path used to skip it.
500            let orchestrator_guardrails = self.guardrails.clone();
501            // SECURITY (audit 2026-06-09): propagate the human-approval gate and
502            // the shared AlwaysAllow/AlwaysDeny store (same shape as F-AGENT-2).
503            let on_approval = self.on_approval.clone();
504            let learned_permissions = self.learned_permissions.clone();
505
506            info!(agent = %agent_def.name, task = %task.task, "spawning sub-agent");
507
508            join_set.spawn(async move {
509                let mut builder = AgentRunner::builder(provider)
510                    .name(&agent_def.name)
511                    .system_prompt(&agent_def.system_prompt)
512                    .tools(agent_def.tools)
513                    .max_turns(max_turns)
514                    .max_tokens(max_tokens);
515
516                if let Some(strategy) = agent_def.context_strategy {
517                    builder = builder.context_strategy(strategy);
518                }
519                if let Some(threshold) = agent_def.summarize_threshold {
520                    builder = builder.summarize_threshold(threshold);
521                }
522                if let Some(timeout) = agent_def.tool_timeout {
523                    builder = builder.tool_timeout(timeout);
524                }
525                if let Some(max) = agent_def.max_tool_output_bytes {
526                    builder = builder.max_tool_output_bytes(max);
527                }
528                if let Some(schema) = agent_def.response_schema {
529                    builder = builder.structured_schema(schema);
530                }
531                // SECURITY (F-AGENT-2): combine orchestrator guardrails with the
532                // sub-agent's own. Both lists are appended (orchestrator first so
533                // global denies fire before agent-local ones). Either may be empty.
534                let mut combined_guardrails = orchestrator_guardrails;
535                combined_guardrails.extend(agent_def.guardrails);
536                if !combined_guardrails.is_empty() {
537                    builder = builder.guardrails(combined_guardrails);
538                }
539                if let Some(timeout) = agent_def.run_timeout {
540                    builder = builder.run_timeout(timeout);
541                }
542                if let Some(effort) = agent_def.reasoning_effort {
543                    builder = builder.reasoning_effort(effort);
544                }
545                if let Some(true) = agent_def.enable_reflection {
546                    builder = builder.enable_reflection(true);
547                }
548                if let Some(threshold) = agent_def.tool_output_compression_threshold {
549                    builder = builder.tool_output_compression_threshold(threshold);
550                }
551                if let Some(max) = agent_def.max_tools_per_turn {
552                    builder = builder.max_tools_per_turn(max);
553                }
554                if let Some(profile) = agent_def.tool_profile {
555                    builder = builder.tool_profile(profile);
556                }
557                if let Some(max) = agent_def.max_identical_tool_calls {
558                    builder = builder.max_identical_tool_calls(max);
559                }
560                if let Some(max) = agent_def.max_fuzzy_identical_tool_calls {
561                    builder = builder.max_fuzzy_identical_tool_calls(max);
562                }
563                if let Some(cap) = agent_def.max_tool_calls_per_turn {
564                    builder = builder.max_tool_calls_per_turn(cap);
565                }
566                if let Some(ref config) = agent_def.session_prune_config {
567                    builder = builder.session_prune_config(config.clone());
568                }
569                if let Some(true) = agent_def.enable_recursive_summarization {
570                    builder = builder.enable_recursive_summarization(true);
571                }
572                if let Some(threshold) = agent_def.reflection_threshold {
573                    builder = builder.reflection_threshold(threshold);
574                }
575                if let Some(true) = agent_def.consolidate_on_exit {
576                    builder = builder.consolidate_on_exit(true);
577                }
578                if let Some(ref ws) = agent_def.workspace {
579                    builder = builder.workspace(ws.clone());
580                }
581                if let Some(max) = agent_def.max_total_tokens {
582                    builder = builder.max_total_tokens(max);
583                }
584                if let Some(trail) = agent_def.audit_trail.clone() {
585                    builder = builder.audit_trail(trail);
586                }
587                if let Some(uid) = &agent_def.audit_user_id
588                    && let Some(tid) = &agent_def.audit_tenant_id
589                {
590                    builder = builder.audit_user_context(uid.clone(), tid.clone());
591                }
592                if !agent_def.audit_delegation_chain.is_empty() {
593                    builder =
594                        builder.audit_delegation_chain(agent_def.audit_delegation_chain.clone());
595                }
596
597                // Forward permission rules from orchestrator to sub-agents
598                if !permission_rules.is_empty() {
599                    builder = builder.permission_rules(permission_rules);
600                }
601
602                // SECURITY (audit 2026-06-09): forward the orchestrator's
603                // human-approval callback so the sub-agent's tool calls are
604                // gated by the same reviewer. Without it, `Ask` rules (and the
605                // legacy batch gate) silently auto-allow in sub-agents.
606                if let Some(ref cb) = on_approval {
607                    builder = builder.on_approval(cb.clone());
608                }
609                // Shared AlwaysAllow/AlwaysDeny memory: one store for the run.
610                if let Some(ref learned) = learned_permissions {
611                    builder = builder.learned_permissions(learned.clone());
612                }
613
614                // Forward observability mode from orchestrator to sub-agents
615                builder = builder.observability_mode(observability_mode);
616
617                // Forward per-tenant token tracker so sub-agent usage counts toward
618                // the same per-tenant cap as the orchestrator.
619                if let Some(ref tracker) = tenant_tracker {
620                    builder = builder.tenant_tracker(tracker.clone());
621                }
622
623                // Forward LSP manager to sub-agents
624                if let Some(ref lsp) = lsp_manager {
625                    builder = builder.lsp_manager(lsp.clone());
626                }
627
628                // Forward on_event so sub-agent events are visible
629                if let Some(ref on_event) = on_event {
630                    builder = builder.on_event(on_event.clone());
631                }
632                // Sub-agents do NOT stream text (contract: see OrchestratorBuilder
633                // ::on_text). Live finding 6a25eb4d: forwarding on_text to N
634                // parallel sub-agents crisscrossed their tokens into one
635                // un-namespaced buffer. Their work stays visible via attributed
636                // tool-call events; only the orchestrator's synthesis streams.
637
638                // Multi-agent context enablement: forward this sub-agent's
639                // context wiring (recitation, restore-on-demand, proactive
640                // compaction, replan). Each field is opt-in; unset → no-op, so
641                // sub-agents with a default `context` are unaffected.
642                if let Some(store) = agent_def.context.todo_store.clone() {
643                    builder = builder.todo_store(store);
644                }
645                if agent_def.context.replan_on_verify_fail {
646                    builder = builder.replan_on_verify_fail(true);
647                }
648                if let Some(window) = agent_def.context.context_window_tokens {
649                    builder = builder.context_window_tokens(window);
650                }
651                if let Some(store) = agent_def.context.context_recall_store.clone() {
652                    builder = builder.context_recall_store(store);
653                }
654
655                // Add memory tools if shared memory is configured
656                if let Some(ref memory) = shared_memory {
657                    let agent_ns = match &ns_prefix {
658                        Some(prefix) => format!("{prefix}:{}", agent_def.name),
659                        None => agent_def.name.clone(),
660                    };
661                    let ns = Arc::new(crate::memory::namespaced::NamespacedMemory::new(
662                        memory.clone(),
663                        &agent_ns,
664                    ));
665                    builder = builder.memory(ns);
666                    let mem_scope = crate::auth::TenantScope::from_audit_fields(
667                        agent_def.audit_tenant_id.as_deref(),
668                        agent_def.audit_user_id.as_deref(),
669                    );
670                    builder = builder.tools(crate::memory::shared_tools::shared_memory_tools(
671                        memory.clone(),
672                        &agent_ns,
673                        mem_scope,
674                        allow_shared_write,
675                    ));
676                }
677
678                // Add blackboard tools if blackboard is configured.
679                // SECURITY (F-AGENT-7): pass the sub-agent name so writes are
680                // caller-namespaced (`caller:{name}/...`) and the reserved
681                // `agent:` prefix is denied to sub-agents.
682                if let Some(ref bb) = blackboard {
683                    let bb_audit = agent_def.audit_trail.clone().map(|trail| {
684                        crate::agent::blackboard_tools::BlackboardAudit {
685                            trail,
686                            user_id: agent_def.audit_user_id.clone(),
687                            tenant_id: agent_def.audit_tenant_id.clone(),
688                        }
689                    });
690                    builder =
691                        builder.tools(blackboard_tools(bb.clone(), &agent_def.name, bb_audit));
692                }
693
694                // Add knowledge tools if knowledge base is configured
695                if let Some(ref kb) = knowledge_base {
696                    builder = builder.knowledge(kb.clone());
697                }
698
699                let runner = match builder.build() {
700                    Ok(r) => r,
701                    Err(e) => {
702                        return (
703                            idx,
704                            SubAgentResult {
705                                agent: agent_def.name,
706                                result: format!("Error building agent: {e}"),
707                                tokens_used: TokenUsage::default(),
708                                success: false,
709                            },
710                        );
711                    }
712                };
713
714                let result = match runner.execute(&task.task).await {
715                    Ok(output) => {
716                        // Write successful result to blackboard (matching Restate path)
717                        if let Some(ref bb) = blackboard {
718                            let key = format!("agent:{}", agent_def.name);
719                            if let Err(e) = bb
720                                .write(&key, serde_json::Value::String(output.result.clone()))
721                                .await
722                            {
723                                tracing::warn!(
724                                    agent = %agent_def.name,
725                                    error = %e,
726                                    "failed to write result to blackboard"
727                                );
728                            }
729                        }
730                        SubAgentResult {
731                            agent: agent_def.name,
732                            result: output.result,
733                            tokens_used: output.tokens_used,
734                            success: true,
735                        }
736                    }
737                    Err(e) => SubAgentResult {
738                        agent: agent_def.name,
739                        result: format!("Error: {e}"),
740                        tokens_used: e.partial_usage(),
741                        success: false,
742                    },
743                };
744
745                (idx, result)
746            });
747        }
748
749        let mut results: Vec<Option<(usize, SubAgentResult)>> = vec![None; task_count];
750        while let Some(result) = join_set.join_next().await {
751            match result {
752                Ok((idx, sub_result)) => {
753                    results[idx] = Some((idx, sub_result));
754                }
755                Err(e) => {
756                    tracing::error!(error = %e, "sub-agent task panicked");
757                }
758            }
759        }
760
761        // Fill gaps (panicked tasks) with error results, then sort by index
762        let mut results: Vec<(usize, SubAgentResult)> = results
763            .into_iter()
764            .enumerate()
765            .map(|(idx, r)| {
766                r.unwrap_or_else(|| {
767                    (
768                        idx,
769                        SubAgentResult {
770                            agent: agent_names[idx].clone(),
771                            result: "Error: sub-agent task panicked".into(),
772                            tokens_used: TokenUsage::default(),
773                            success: false,
774                        },
775                    )
776                })
777            })
778            .collect();
779        results.sort_by_key(|(idx, _)| *idx);
780
781        // Accumulate sub-agent tokens (lock scope kept minimal — no callbacks inside)
782        {
783            let mut acc = self.accumulated_tokens.lock().expect("token lock poisoned");
784            for (_, r) in &results {
785                *acc += r.tokens_used;
786            }
787        }
788
789        // Emit completion events outside the lock
790        if let Some(ref cb) = self.on_event {
791            for (_, r) in &results {
792                cb(AgentEvent::SubAgentCompleted {
793                    agent: r.agent.clone(),
794                    success: r.success,
795                    usage: r.tokens_used,
796                });
797            }
798        }
799
800        let formatted = results
801            .iter()
802            .map(|(_, r)| format!("=== Agent: {} ===\n{}", r.agent, r.result))
803            .collect::<Vec<_>>()
804            .join("\n\n");
805
806        Ok(formatted)
807    }
808}
809
810impl Tool for DelegateTaskTool {
811    fn definition(&self) -> ToolDefinition {
812        self.cached_definition.clone()
813    }
814
815    fn execute(
816        &self,
817        _ctx: &crate::ExecutionContext,
818        input: serde_json::Value,
819    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
820        Box::pin(async move {
821            let delegate_input: DelegateInput = serde_json::from_value(input)
822                .map_err(|e| Error::Agent(format!("Invalid delegate_task input: {e}")))?;
823
824            // Orchestration nudge (live finding 6a25eb4d): a same-agent parallel
825            // fan-out (N tasks to ONE isolated agent) is the interdependent-work
826            // anti-pattern. Refuse it ONCE with actionable guidance; an explicit
827            // identical retry dispatches. This is a NUDGE, not a guaranteed
828            // prevention — we can't tell interdependent from independent on this
829            // topology — so both the refusal AND the retry-through are traced
830            // (GateFired) to make prevent-vs-nudge empirical.
831            if let Some((agent, count)) =
832                same_agent_fanout(&delegate_input.tasks, SAME_AGENT_FANOUT_NUDGE_AT)
833            {
834                let fp = batch_fingerprint(&delegate_input.tasks);
835                let is_retry = {
836                    let mut last = self
837                        .fanout_refused
838                        .lock()
839                        .expect("fanout_refused lock poisoned");
840                    if *last == Some(fp) {
841                        *last = None; // explicit retry consumes the one-shot
842                        true
843                    } else {
844                        *last = Some(fp);
845                        false
846                    }
847                };
848                if is_retry {
849                    if let Some(cb) = &self.on_event {
850                        cb(AgentEvent::GateFired {
851                            agent: "orchestrator".into(),
852                            gate: "same_agent_fanout_dispatched".into(),
853                            reason: format!(
854                                "{count} parallel tasks to '{agent}' dispatched after nudge \
855                                 (retry-through)"
856                            ),
857                        });
858                    }
859                } else {
860                    if let Some(cb) = &self.on_event {
861                        cb(AgentEvent::GateFired {
862                            agent: "orchestrator".into(),
863                            gate: "same_agent_fanout".into(),
864                            reason: format!(
865                                "{count} parallel tasks delegated to the same agent '{agent}'"
866                            ),
867                        });
868                    }
869                    return Ok(ToolOutput::error(fanout_guidance(&agent, count)));
870                }
871            }
872
873            let result = self.delegate(delegate_input.tasks).await?;
874            Ok(ToolOutput::success(result))
875        })
876    }
877}
878
879#[derive(Deserialize)]
880struct DelegateInput {
881    tasks: Vec<DelegatedTask>,
882}
883
884/// The orchestrator's squad-formation tool: dispatches per-agent tasks with a
885/// shared private blackboard for intra-squad coordination.
886///
887/// Unlike `DelegateTaskTool` which runs agents independently with the outer blackboard,
888/// `FormSquadTool` creates a private `InMemoryBlackboard` so squad members can read
889/// each other's intermediate results without polluting the outer blackboard. The final
890/// formatted result is written to the outer blackboard under `"squad:{names}"`.
891///
892/// Accepts the same `{tasks:[{agent, task}]}` format as `delegate_task`.
893struct FormSquadTool {
894    shared_provider: Arc<BoxedProvider>,
895    agent_pool: Vec<SubAgentDef>,
896    default_max_turns: usize,
897    default_max_tokens: u32,
898    /// Permission rules inherited from the orchestrator, forwarded to squad members.
899    permission_rules: super::permission::PermissionRuleset,
900    accumulated_tokens: Arc<Mutex<TokenUsage>>,
901    shared_memory: Option<Arc<dyn Memory>>,
902    memory_namespace_prefix: Option<String>,
903    /// Outer blackboard for writing squad results. Squad members use a private one.
904    blackboard: Option<Arc<dyn Blackboard>>,
905    knowledge_base: Option<Arc<dyn KnowledgeBase>>,
906    on_event: Option<Arc<OnEvent>>,
907    /// Optional LSP manager, forwarded to squad members.
908    lsp_manager: Option<Arc<crate::lsp::LspManager>>,
909    cached_definition: ToolDefinition,
910    /// Observability mode inherited from the orchestrator, forwarded to squad members.
911    observability_mode: super::observability::ObservabilityMode,
912    /// Whether squad members may write to shared institutional memory.
913    allow_shared_write: bool,
914    /// Optional per-tenant token tracker propagated to all squad member runners.
915    tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
916    /// SECURITY (F-AGENT-2): orchestrator-level guardrails, combined with each
917    /// squad member's own so squad work can't bypass the orchestrator's defenses
918    /// (the `delegate_task` path already does this; `form_squad` used to skip it).
919    guardrails: Vec<Arc<dyn Guardrail>>,
920    /// Human-approval callback propagated to squad members.
921    /// SECURITY (audit 2026-06-09): same gate as `delegate_task` — see
922    /// `DelegateTaskTool::on_approval`.
923    on_approval: Option<Arc<crate::llm::OnApproval>>,
924    /// Shared AlwaysAllow/AlwaysDeny store propagated to squad members
925    /// (one ruleset for the whole run).
926    learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
927}
928
929impl Tool for FormSquadTool {
930    fn definition(&self) -> ToolDefinition {
931        self.cached_definition.clone()
932    }
933
934    fn execute(
935        &self,
936        _ctx: &crate::ExecutionContext,
937        input: serde_json::Value,
938    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
939        Box::pin(async move {
940            let delegate_input: DelegateInput = serde_json::from_value(input)
941                .map_err(|e| Error::Agent(format!("Invalid form_squad input: {e}")))?;
942
943            let tasks = delegate_input.tasks;
944
945            // Validate: at least 2 tasks (agents)
946            if tasks.len() < 2 {
947                return Ok(ToolOutput::error(
948                    "form_squad requires at least 2 tasks. Use delegate_task for single-agent tasks."
949                        .to_string(),
950                ));
951            }
952
953            // Validate: no duplicate agent names. Live finding: a model sent
954            // ["worker","worker"] wanting PARALLEL same-agent tasks and the
955            // bare error left it retrying — name the alternatives.
956            {
957                let mut seen = std::collections::HashSet::new();
958                for t in &tasks {
959                    if !seen.insert(&t.agent) {
960                        return Ok(ToolOutput::error(format!(
961                            "Duplicate agent name in squad: '{}'. Each form_squad \
962                             task needs a DISTINCT agent (available: {}). To run \
963                             several tasks on the SAME agent in parallel, use \
964                             delegate_task with multiple tasks instead.",
965                            t.agent,
966                            self.agent_pool
967                                .iter()
968                                .map(|a| a.name.as_str())
969                                .collect::<Vec<_>>()
970                                .join(", ")
971                        )));
972                    }
973                }
974            }
975
976            // Validate all agents exist before spawning any
977            for t in &tasks {
978                if !self.agent_pool.iter().any(|a| a.name == t.agent) {
979                    return Ok(ToolOutput::error(format!(
980                        "Unknown agent '{}'. Available agents: {}",
981                        t.agent,
982                        self.agent_pool
983                            .iter()
984                            .map(|a| a.name.as_str())
985                            .collect::<Vec<_>>()
986                            .join(", ")
987                    )));
988                }
989            }
990
991            let task_count = tasks.len();
992            let agent_names: Vec<String> = tasks.iter().map(|t| t.agent.clone()).collect();
993
994            // Create private blackboard for intra-squad coordination
995            let private_bb: Arc<dyn Blackboard> = Arc::new(InMemoryBlackboard::new());
996
997            let _squad_span = info_span!(
998                "heartbit.orchestrator.squad",
999                agent_count = task_count,
1000                agents = ?agent_names,
1001            );
1002
1003            if let Some(ref cb) = self.on_event {
1004                cb(AgentEvent::SubAgentsDispatched {
1005                    agent: "squad-leader".into(),
1006                    agents: agent_names.clone(),
1007                });
1008            }
1009
1010            // Direct dispatch via JoinSet — no mini-orchestrator
1011            let mut join_set = tokio::task::JoinSet::new();
1012
1013            for (idx, task) in tasks.into_iter().enumerate() {
1014                // Agent existence already validated above, but avoid panic in library code
1015                let agent_def = match self.agent_pool.iter().find(|a| a.name == task.agent) {
1016                    Some(def) => def.clone(),
1017                    None => {
1018                        return Ok(ToolOutput::error(format!(
1019                            "Internal error: agent '{}' not found after validation",
1020                            task.agent
1021                        )));
1022                    }
1023                };
1024
1025                let provider = agent_def
1026                    .provider_override
1027                    .clone()
1028                    .unwrap_or_else(|| self.shared_provider.clone());
1029                let max_turns = agent_def.max_turns.unwrap_or(self.default_max_turns);
1030                let max_tokens = agent_def.max_tokens.unwrap_or(self.default_max_tokens);
1031                let shared_memory = self.shared_memory.clone();
1032                let ns_prefix = self.memory_namespace_prefix.clone();
1033                let bb = private_bb.clone();
1034                let knowledge_base = self.knowledge_base.clone();
1035                let on_event = self.on_event.clone();
1036                let lsp_manager = self.lsp_manager.clone();
1037                let permission_rules = self.permission_rules.clone();
1038                let observability_mode = self.observability_mode;
1039                let allow_shared_write = self.allow_shared_write;
1040                let tenant_tracker = self.tenant_tracker.clone();
1041                // SECURITY (F-AGENT-2): propagate orchestrator guardrails to squad members.
1042                let squad_guardrails = self.guardrails.clone();
1043                // SECURITY (audit 2026-06-09): propagate the human-approval gate and
1044                // the shared AlwaysAllow/AlwaysDeny store to squad members too.
1045                let on_approval = self.on_approval.clone();
1046                let learned_permissions = self.learned_permissions.clone();
1047
1048                info!(agent = %agent_def.name, task = %task.task, "spawning squad member");
1049
1050                join_set.spawn(async move {
1051                    let mut builder = AgentRunner::builder(provider)
1052                        .name(&agent_def.name)
1053                        .system_prompt(&agent_def.system_prompt)
1054                        .tools(agent_def.tools)
1055                        .max_turns(max_turns)
1056                        .max_tokens(max_tokens);
1057
1058                    if let Some(strategy) = agent_def.context_strategy {
1059                        builder = builder.context_strategy(strategy);
1060                    }
1061                    if let Some(threshold) = agent_def.summarize_threshold {
1062                        builder = builder.summarize_threshold(threshold);
1063                    }
1064                    if let Some(timeout) = agent_def.tool_timeout {
1065                        builder = builder.tool_timeout(timeout);
1066                    }
1067                    if let Some(max) = agent_def.max_tool_output_bytes {
1068                        builder = builder.max_tool_output_bytes(max);
1069                    }
1070                    if let Some(schema) = agent_def.response_schema {
1071                        builder = builder.structured_schema(schema);
1072                    }
1073                    // SECURITY (F-AGENT-2): combine orchestrator + member guardrails
1074                    // so squad work runs under the orchestrator's defenses too.
1075                    let mut combined_guardrails = squad_guardrails;
1076                    combined_guardrails.extend(agent_def.guardrails);
1077                    if !combined_guardrails.is_empty() {
1078                        builder = builder.guardrails(combined_guardrails);
1079                    }
1080                    if let Some(timeout) = agent_def.run_timeout {
1081                        builder = builder.run_timeout(timeout);
1082                    }
1083                    if let Some(effort) = agent_def.reasoning_effort {
1084                        builder = builder.reasoning_effort(effort);
1085                    }
1086                    if let Some(true) = agent_def.enable_reflection {
1087                        builder = builder.enable_reflection(true);
1088                    }
1089                    if let Some(threshold) = agent_def.tool_output_compression_threshold {
1090                        builder = builder.tool_output_compression_threshold(threshold);
1091                    }
1092                    if let Some(max) = agent_def.max_tools_per_turn {
1093                        builder = builder.max_tools_per_turn(max);
1094                    }
1095                    if let Some(profile) = agent_def.tool_profile {
1096                        builder = builder.tool_profile(profile);
1097                    }
1098                    if let Some(max) = agent_def.max_identical_tool_calls {
1099                        builder = builder.max_identical_tool_calls(max);
1100                    }
1101                    if let Some(max) = agent_def.max_fuzzy_identical_tool_calls {
1102                        builder = builder.max_fuzzy_identical_tool_calls(max);
1103                    }
1104                    if let Some(cap) = agent_def.max_tool_calls_per_turn {
1105                        builder = builder.max_tool_calls_per_turn(cap);
1106                    }
1107                    if let Some(ref config) = agent_def.session_prune_config {
1108                        builder = builder.session_prune_config(config.clone());
1109                    }
1110                    if let Some(true) = agent_def.enable_recursive_summarization {
1111                        builder = builder.enable_recursive_summarization(true);
1112                    }
1113                    if let Some(threshold) = agent_def.reflection_threshold {
1114                        builder = builder.reflection_threshold(threshold);
1115                    }
1116                    if let Some(true) = agent_def.consolidate_on_exit {
1117                        builder = builder.consolidate_on_exit(true);
1118                    }
1119                    if let Some(ref ws) = agent_def.workspace {
1120                        builder = builder.workspace(ws.clone());
1121                    }
1122                    if let Some(max) = agent_def.max_total_tokens {
1123                        builder = builder.max_total_tokens(max);
1124                    }
1125                    if let Some(trail) = agent_def.audit_trail.clone() {
1126                        builder = builder.audit_trail(trail);
1127                    }
1128                    if let Some(uid) = &agent_def.audit_user_id
1129                        && let Some(tid) = &agent_def.audit_tenant_id
1130                    {
1131                        builder = builder.audit_user_context(uid.clone(), tid.clone());
1132                    }
1133                    if !agent_def.audit_delegation_chain.is_empty() {
1134                        builder = builder
1135                            .audit_delegation_chain(agent_def.audit_delegation_chain.clone());
1136                    }
1137
1138                    // Forward permission rules from orchestrator to squad members
1139                    if !permission_rules.is_empty() {
1140                        builder = builder.permission_rules(permission_rules);
1141                    }
1142
1143                    // SECURITY (audit 2026-06-09): forward the human-approval
1144                    // gate so squad members' tool calls are gated by the same
1145                    // reviewer as the orchestrator's own.
1146                    if let Some(ref cb) = on_approval {
1147                        builder = builder.on_approval(cb.clone());
1148                    }
1149                    // Shared AlwaysAllow/AlwaysDeny memory: one store for the run.
1150                    if let Some(ref learned) = learned_permissions {
1151                        builder = builder.learned_permissions(learned.clone());
1152                    }
1153
1154                    // Forward observability mode from orchestrator to squad members
1155                    builder = builder.observability_mode(observability_mode);
1156
1157                    // Forward per-tenant token tracker so squad member usage counts
1158                    // toward the same per-tenant cap as the orchestrator.
1159                    if let Some(ref tracker) = tenant_tracker {
1160                        builder = builder.tenant_tracker(tracker.clone());
1161                    }
1162
1163                    // Forward LSP manager to squad members
1164                    if let Some(ref lsp) = lsp_manager {
1165                        builder = builder.lsp_manager(lsp.clone());
1166                    }
1167
1168                    // Forward on_event so sub-agent events are visible
1169                    if let Some(ref on_event) = on_event {
1170                        builder = builder.on_event(on_event.clone());
1171                    }
1172                    // Squad members do NOT stream text — see DelegateTaskTool.
1173
1174                    // Multi-agent context enablement (form_squad path): forward
1175                    // this squad member's context wiring, mirroring the delegate
1176                    // path. Opt-in; unset → no-op.
1177                    if let Some(store) = agent_def.context.todo_store.clone() {
1178                        builder = builder.todo_store(store);
1179                    }
1180                    if agent_def.context.replan_on_verify_fail {
1181                        builder = builder.replan_on_verify_fail(true);
1182                    }
1183                    if let Some(window) = agent_def.context.context_window_tokens {
1184                        builder = builder.context_window_tokens(window);
1185                    }
1186                    if let Some(store) = agent_def.context.context_recall_store.clone() {
1187                        builder = builder.context_recall_store(store);
1188                    }
1189
1190                    // Add memory tools if shared memory is configured
1191                    if let Some(ref memory) = shared_memory {
1192                        let agent_ns = match &ns_prefix {
1193                            Some(prefix) => format!("{prefix}:{}", agent_def.name),
1194                            None => agent_def.name.clone(),
1195                        };
1196                        let ns = Arc::new(crate::memory::namespaced::NamespacedMemory::new(
1197                            memory.clone(),
1198                            &agent_ns,
1199                        ));
1200                        builder = builder.memory(ns);
1201                        let mem_scope = crate::auth::TenantScope::from_audit_fields(
1202                            agent_def.audit_tenant_id.as_deref(),
1203                            agent_def.audit_user_id.as_deref(),
1204                        );
1205                        builder = builder.tools(crate::memory::shared_tools::shared_memory_tools(
1206                            memory.clone(),
1207                            &agent_ns,
1208                            mem_scope,
1209                            allow_shared_write,
1210                        ));
1211                    }
1212
1213                    // Add blackboard tools using the PRIVATE blackboard.
1214                    // SECURITY (F-AGENT-7): caller-namespaced; reserved
1215                    // `agent:` prefix denied to sub-agents.
1216                    let bb_audit = agent_def.audit_trail.clone().map(|trail| {
1217                        crate::agent::blackboard_tools::BlackboardAudit {
1218                            trail,
1219                            user_id: agent_def.audit_user_id.clone(),
1220                            tenant_id: agent_def.audit_tenant_id.clone(),
1221                        }
1222                    });
1223                    builder =
1224                        builder.tools(blackboard_tools(bb.clone(), &agent_def.name, bb_audit));
1225
1226                    // Add knowledge tools if knowledge base is configured
1227                    if let Some(ref kb) = knowledge_base {
1228                        builder = builder.knowledge(kb.clone());
1229                    }
1230
1231                    let runner = match builder.build() {
1232                        Ok(r) => r,
1233                        Err(e) => {
1234                            return (
1235                                idx,
1236                                SubAgentResult {
1237                                    agent: agent_def.name,
1238                                    result: format!("Error building agent: {e}"),
1239                                    tokens_used: TokenUsage::default(),
1240                                    success: false,
1241                                },
1242                            );
1243                        }
1244                    };
1245
1246                    let result = match runner.execute(&task.task).await {
1247                        Ok(output) => {
1248                            // Write successful result to private blackboard
1249                            let key = format!("agent:{}", agent_def.name);
1250                            if let Err(e) = bb
1251                                .write(&key, serde_json::Value::String(output.result.clone()))
1252                                .await
1253                            {
1254                                tracing::warn!(
1255                                    agent = %agent_def.name,
1256                                    error = %e,
1257                                    "failed to write result to private blackboard"
1258                                );
1259                            }
1260                            SubAgentResult {
1261                                agent: agent_def.name,
1262                                result: output.result,
1263                                tokens_used: output.tokens_used,
1264                                success: true,
1265                            }
1266                        }
1267                        Err(e) => SubAgentResult {
1268                            agent: agent_def.name,
1269                            result: format!("Error: {e}"),
1270                            tokens_used: e.partial_usage(),
1271                            success: false,
1272                        },
1273                    };
1274
1275                    (idx, result)
1276                });
1277            }
1278
1279            let mut results: Vec<Option<(usize, SubAgentResult)>> = vec![None; task_count];
1280            while let Some(result) = join_set.join_next().await {
1281                match result {
1282                    Ok((idx, sub_result)) => {
1283                        results[idx] = Some((idx, sub_result));
1284                    }
1285                    Err(e) => {
1286                        tracing::error!(error = %e, "squad member task panicked");
1287                    }
1288                }
1289            }
1290
1291            // Fill gaps (panicked tasks) with error results, then sort by index
1292            let mut results: Vec<(usize, SubAgentResult)> = results
1293                .into_iter()
1294                .enumerate()
1295                .map(|(idx, r)| {
1296                    r.unwrap_or_else(|| {
1297                        (
1298                            idx,
1299                            SubAgentResult {
1300                                agent: agent_names[idx].clone(),
1301                                result: "Error: squad member task panicked".into(),
1302                                tokens_used: TokenUsage::default(),
1303                                success: false,
1304                            },
1305                        )
1306                    })
1307                })
1308                .collect();
1309            results.sort_by_key(|(idx, _)| *idx);
1310
1311            let squad_label = format!("squad[{}]", agent_names.join(","));
1312            let bb_key = format!("squad:{}", agent_names.join("+"));
1313
1314            // Accumulate sub-agent tokens (lock scope kept minimal)
1315            let mut total_tokens = TokenUsage::default();
1316            {
1317                let mut acc = self.accumulated_tokens.lock().expect("token lock poisoned");
1318                for (_, r) in &results {
1319                    *acc += r.tokens_used;
1320                    total_tokens += r.tokens_used;
1321                }
1322            }
1323
1324            // Emit per-agent completion events
1325            if let Some(ref cb) = self.on_event {
1326                for (_, r) in &results {
1327                    cb(AgentEvent::SubAgentCompleted {
1328                        agent: r.agent.clone(),
1329                        success: r.success,
1330                        usage: r.tokens_used,
1331                    });
1332                }
1333            }
1334
1335            let all_success = results.iter().all(|(_, r)| r.success);
1336
1337            let formatted = results
1338                .iter()
1339                .map(|(_, r)| format!("=== Agent: {} ===\n{}", r.agent, r.result))
1340                .collect::<Vec<_>>()
1341                .join("\n\n");
1342
1343            // Emit aggregate squad completion event
1344            if let Some(ref cb) = self.on_event {
1345                cb(AgentEvent::SubAgentCompleted {
1346                    agent: squad_label,
1347                    success: all_success,
1348                    usage: total_tokens,
1349                });
1350            }
1351
1352            // Write squad result to outer blackboard
1353            if let Some(ref bb) = self.blackboard
1354                && let Err(e) = bb
1355                    .write(&bb_key, serde_json::Value::String(formatted.clone()))
1356                    .await
1357            {
1358                tracing::warn!(
1359                    key = %bb_key,
1360                    error = %e,
1361                    "failed to write squad result to outer blackboard"
1362                );
1363            }
1364
1365            Ok(ToolOutput::success(formatted))
1366        })
1367    }
1368}
1369
1370/// The orchestrator's dynamic agent spawning tool: creates specialist agents at runtime.
1371///
1372/// Unlike `DelegateTaskTool` which dispatches to pre-configured agents, `SpawnAgentTool`
1373/// lets the LLM define new agents on-the-fly with a custom system prompt and tool subset.
1374/// Security: tool allowlist enforced, spawn count capped, token budget tracked, no recursion.
1375struct SpawnAgentTool {
1376    shared_provider: Arc<BoxedProvider>,
1377    spawn_config: crate::types::SpawnConfig,
1378    /// Pre-built tools from allowlist (validated at build time).
1379    tool_pool: std::collections::HashMap<String, Arc<dyn Tool>>,
1380    /// Tracks how many agents have been spawned this run.
1381    spawn_count: Arc<std::sync::atomic::AtomicU32>,
1382    /// Prevents name reuse within a single run.
1383    spawned_names: Arc<Mutex<std::collections::HashSet<String>>>,
1384    /// Tracks cumulative token usage across all spawned agents.
1385    accumulated_tokens: Arc<Mutex<TokenUsage>>,
1386    permission_rules: super::permission::PermissionRuleset,
1387    shared_memory: Option<Arc<dyn Memory>>,
1388    memory_namespace_prefix: Option<String>,
1389    on_event: Option<Arc<OnEvent>>,
1390    lsp_manager: Option<Arc<crate::lsp::LspManager>>,
1391    observability_mode: super::observability::ObservabilityMode,
1392    workspace: Option<std::path::PathBuf>,
1393    guardrails: Vec<Arc<dyn Guardrail>>,
1394    audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
1395    audit_user_id: Option<String>,
1396    audit_tenant_id: Option<String>,
1397    audit_delegation_chain: Vec<String>,
1398    cached_definition: ToolDefinition,
1399    /// Optional per-tenant token tracker propagated to each spawned agent runner.
1400    tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
1401    /// Human-approval callback propagated to spawned agents.
1402    /// SECURITY (audit 2026-06-09): same gate as `delegate_task` — see
1403    /// `DelegateTaskTool::on_approval`.
1404    on_approval: Option<Arc<crate::llm::OnApproval>>,
1405    /// Shared AlwaysAllow/AlwaysDeny store propagated to spawned agents
1406    /// (one ruleset for the whole run).
1407    learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
1408}
1409
1410#[derive(Deserialize)]
1411struct SpawnAgentInput {
1412    name: String,
1413    system_prompt: String,
1414    #[serde(default)]
1415    tools: Vec<String>,
1416    task: String,
1417}
1418
1419/// Maximum allowed system prompt length for spawned agents (32 KB).
1420const SPAWN_MAX_PROMPT_BYTES: usize = 32 * 1024;
1421
1422impl SpawnAgentTool {
1423    fn build_definition(config: &crate::types::SpawnConfig) -> ToolDefinition {
1424        let allowlist = if config.tool_allowlist.is_empty() {
1425            "(none — reasoning-only agents)".to_string()
1426        } else {
1427            config.tool_allowlist.join(", ")
1428        };
1429        ToolDefinition {
1430            name: "spawn_agent".into(),
1431            description: format!(
1432                "Create a new specialist agent at runtime when no pre-configured agent fits the task. \
1433                 The spawned agent runs with the given system prompt and tool subset, then returns its result.\n\n\
1434                 Available tools for spawned agents: [{allowlist}]. Budget: {} agents max per run.",
1435                config.max_spawned_agents
1436            ),
1437            input_schema: json!({
1438                "type": "object",
1439                "required": ["name", "system_prompt", "task"],
1440                "properties": {
1441                    "name": {
1442                        "type": "string",
1443                        "description": "Lowercase identifier for the agent (a-z, 0-9, underscores). Must start with a letter. E.g. 'tax_specialist', 'csv_analyzer'."
1444                    },
1445                    "system_prompt": {
1446                        "type": "string",
1447                        "description": "The agent's role and behavior instructions. Be specific about expertise and constraints."
1448                    },
1449                    "tools": {
1450                        "type": "array",
1451                        "items": { "type": "string" },
1452                        "description": format!("Subset of available tools: [{allowlist}]. Empty array creates a reasoning-only agent.")
1453                    },
1454                    "task": {
1455                        "type": "string",
1456                        "description": "The specific task for this agent to accomplish."
1457                    }
1458                },
1459                "additionalProperties": false
1460            }),
1461        }
1462    }
1463
1464    async fn spawn(&self, input: SpawnAgentInput) -> Result<ToolOutput, Error> {
1465        use std::sync::atomic::{AtomicU32, Ordering};
1466
1467        // 1. Spawn count cap — reserve a slot ATOMICALLY. A1: the previous
1468        // load-then-check let N concurrent `spawn_agent` tool calls in one turn
1469        // all observe `count < max` and overshoot the cap, because the count was
1470        // only incremented AFTER each child finished. `fetch_add` reserves the
1471        // slot up front; the RAII guard rolls the reservation back on any early
1472        // return (validation failure, build error) and is committed once the
1473        // agent actually executes (a real execution legitimately consumes a slot).
1474        struct SlotGuard<'a> {
1475            counter: &'a AtomicU32,
1476            committed: bool,
1477        }
1478        impl Drop for SlotGuard<'_> {
1479            fn drop(&mut self) {
1480                if !self.committed {
1481                    self.counter.fetch_sub(1, Ordering::AcqRel);
1482                }
1483            }
1484        }
1485
1486        let reserved = self.spawn_count.fetch_add(1, Ordering::AcqRel);
1487        let mut slot = SlotGuard {
1488            counter: &self.spawn_count,
1489            committed: false,
1490        };
1491        if reserved >= self.spawn_config.max_spawned_agents {
1492            return Ok(ToolOutput::error(format!(
1493                "Spawn limit reached: {reserved}/{} agents already spawned this run.",
1494                self.spawn_config.max_spawned_agents
1495            )));
1496        }
1497
1498        // 2. Name format validation
1499        let name_re =
1500            regex::Regex::new(r"^[a-z][a-z0-9_]{0,63}$").expect("spawn agent name regex is valid");
1501        if !name_re.is_match(&input.name) {
1502            return Ok(ToolOutput::error(format!(
1503                "Invalid agent name '{}'. Must match ^[a-z][a-z0-9_]{{0,63}}$ \
1504                 (lowercase, starts with letter, alphanumeric + underscores, max 64 chars).",
1505                input.name
1506            )));
1507        }
1508
1509        // 3. Name uniqueness
1510        {
1511            let mut names = self.spawned_names.lock().expect("spawned names lock");
1512            if !names.insert(input.name.clone()) {
1513                return Ok(ToolOutput::error(format!(
1514                    "Agent name '{}' already used in this run. Choose a different name.",
1515                    input.name
1516                )));
1517            }
1518        }
1519
1520        // 4. Tool subset validation
1521        for tool_name in &input.tools {
1522            if !self.tool_pool.contains_key(tool_name) {
1523                let available: Vec<&str> = self.tool_pool.keys().map(|k| k.as_str()).collect();
1524                return Ok(ToolOutput::error(format!(
1525                    "Tool '{}' not in allowlist. Available: [{}]",
1526                    tool_name,
1527                    available.join(", ")
1528                )));
1529            }
1530        }
1531
1532        // 5. Token budget headroom check
1533        {
1534            let acc = self.accumulated_tokens.lock().expect("token lock");
1535            let used = acc.total();
1536            if used >= self.spawn_config.max_total_tokens {
1537                return Ok(ToolOutput::error(format!(
1538                    "Spawn token budget exhausted: {used}/{} tokens used across spawned agents.",
1539                    self.spawn_config.max_total_tokens
1540                )));
1541            }
1542        }
1543
1544        // 6. System prompt length check
1545        if input.system_prompt.len() > SPAWN_MAX_PROMPT_BYTES {
1546            return Ok(ToolOutput::error(format!(
1547                "System prompt too long: {} bytes (max {SPAWN_MAX_PROMPT_BYTES}).",
1548                input.system_prompt.len()
1549            )));
1550        }
1551
1552        let spawned_name = format!("spawn:{}", input.name);
1553
1554        // Emit AgentSpawned event
1555        if let Some(ref cb) = self.on_event {
1556            cb(AgentEvent::AgentSpawned {
1557                agent: "orchestrator".into(),
1558                spawned_name: spawned_name.clone(),
1559                tools: input.tools.clone(),
1560                task: input.task.clone(),
1561            });
1562        }
1563
1564        // Build tool set from requested subset
1565        let selected_tools: Vec<Arc<dyn Tool>> = input
1566            .tools
1567            .iter()
1568            .filter_map(|name| self.tool_pool.get(name).cloned())
1569            .collect();
1570
1571        // Build AgentRunner
1572        let mut builder = AgentRunner::builder(self.shared_provider.clone())
1573            .name(&spawned_name)
1574            .system_prompt(&input.system_prompt)
1575            .tools(selected_tools)
1576            .max_turns(self.spawn_config.max_turns)
1577            .max_tokens(self.spawn_config.max_tokens)
1578            .observability_mode(self.observability_mode);
1579
1580        // Inherit security state from orchestrator
1581        if !self.permission_rules.is_empty() {
1582            builder = builder.permission_rules(self.permission_rules.clone());
1583        }
1584        if !self.guardrails.is_empty() {
1585            builder = builder.guardrails(self.guardrails.clone());
1586        }
1587        // SECURITY (audit 2026-06-09): forward the human-approval gate so the
1588        // spawned agent's tool calls are gated by the same reviewer.
1589        if let Some(ref cb) = self.on_approval {
1590            builder = builder.on_approval(cb.clone());
1591        }
1592        // Shared AlwaysAllow/AlwaysDeny memory: one store for the run.
1593        if let Some(ref learned) = self.learned_permissions {
1594            builder = builder.learned_permissions(learned.clone());
1595        }
1596        if let Some(ref ws) = self.workspace {
1597            builder = builder.workspace(ws.clone());
1598        }
1599        if let Some(ref lsp) = self.lsp_manager {
1600            builder = builder.lsp_manager(lsp.clone());
1601        }
1602        if let Some(ref cb) = self.on_event {
1603            builder = builder.on_event(cb.clone());
1604        }
1605        // Spawned agents do NOT stream text — see DelegateTaskTool.
1606        if let Some(ref trail) = self.audit_trail {
1607            builder = builder.audit_trail(trail.clone());
1608        }
1609        if let (Some(uid), Some(tid)) = (&self.audit_user_id, &self.audit_tenant_id) {
1610            builder = builder.audit_user_context(uid.clone(), tid.clone());
1611        }
1612        if !self.audit_delegation_chain.is_empty() {
1613            let mut chain = self.audit_delegation_chain.clone();
1614            chain.push(spawned_name.clone());
1615            builder = builder.audit_delegation_chain(chain);
1616        }
1617
1618        // Forward per-tenant token tracker so spawned agent usage counts toward
1619        // the same per-tenant cap as the orchestrator.
1620        if let Some(ref tracker) = self.tenant_tracker {
1621            builder = builder.tenant_tracker(tracker.clone());
1622        }
1623
1624        // Memory: read-only shared access with namespaced isolation
1625        if let Some(ref memory) = self.shared_memory {
1626            let agent_ns = match &self.memory_namespace_prefix {
1627                Some(prefix) => format!("{prefix}:{spawned_name}"),
1628                None => spawned_name.clone(),
1629            };
1630            let ns = Arc::new(crate::memory::namespaced::NamespacedMemory::new(
1631                memory.clone(),
1632                &agent_ns,
1633            ));
1634            builder = builder.memory(ns);
1635            let mem_scope = crate::auth::TenantScope::from_audit_fields(
1636                self.audit_tenant_id.as_deref(),
1637                self.audit_user_id.as_deref(),
1638            );
1639            builder = builder.tools(crate::memory::shared_tools::shared_memory_tools(
1640                memory.clone(),
1641                &agent_ns,
1642                mem_scope,
1643                false, // read-only: spawned agents cannot write to shared memory
1644            ));
1645        }
1646
1647        let runner = builder.build()?;
1648
1649        // The slot is now consumed by a real execution: commit the reservation
1650        // so it is NOT rolled back when `slot` drops at the end of the function.
1651        slot.committed = true;
1652
1653        info!(
1654            agent = %spawned_name,
1655            tools = ?input.tools,
1656            "spawning dynamic agent"
1657        );
1658
1659        // Execute
1660        match runner.execute(&input.task).await {
1661            Ok(output) => {
1662                // Post-execution bookkeeping (the count slot was reserved up front).
1663                {
1664                    let mut acc = self.accumulated_tokens.lock().expect("token lock");
1665                    *acc += output.tokens_used;
1666                }
1667                if let Some(ref cb) = self.on_event {
1668                    cb(AgentEvent::SubAgentCompleted {
1669                        agent: spawned_name.clone(),
1670                        success: true,
1671                        usage: output.tokens_used,
1672                    });
1673                }
1674                Ok(ToolOutput::success(format!(
1675                    "=== Spawned Agent: {} ===\n{}",
1676                    spawned_name, output.result
1677                )))
1678            }
1679            Err(e) => {
1680                let partial = e.partial_usage();
1681                {
1682                    let mut acc = self.accumulated_tokens.lock().expect("token lock");
1683                    *acc += partial;
1684                }
1685                if let Some(ref cb) = self.on_event {
1686                    cb(AgentEvent::SubAgentCompleted {
1687                        agent: spawned_name.clone(),
1688                        success: false,
1689                        usage: partial,
1690                    });
1691                }
1692                Ok(ToolOutput::error(format!(
1693                    "Spawned agent '{spawned_name}' failed: {e}"
1694                )))
1695            }
1696        }
1697    }
1698}
1699
1700impl Tool for SpawnAgentTool {
1701    fn definition(&self) -> ToolDefinition {
1702        self.cached_definition.clone()
1703    }
1704
1705    fn execute(
1706        &self,
1707        _ctx: &crate::ExecutionContext,
1708        input: serde_json::Value,
1709    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
1710        Box::pin(async move {
1711            let spawn_input: SpawnAgentInput = serde_json::from_value(input)
1712                .map_err(|e| Error::Agent(format!("Invalid spawn_agent input: {e}")))?;
1713            self.spawn(spawn_input).await
1714        })
1715    }
1716}
1717
1718/// Build the orchestrator system prompt listing available agents.
1719///
1720/// Shared between standalone and Restate paths. Takes `(name, description, tool_names)` triples.
1721///
1722/// When `squads_enabled` is `true`, the prompt explains both `delegate_task` and `form_squad`
1723/// tools. When `false`, only `delegate_task` is mentioned (Restate path or opt-out).
1724///
1725/// **Note:** Only the agent's registered tools are listed. Runtime-injected tools
1726/// (memory, blackboard, knowledge) are shared infrastructure available to all agents
1727/// and are not shown here to avoid noise in the prompt.
1728pub fn build_system_prompt(
1729    agents: &[(&str, &str, &[String])],
1730    squads_enabled: bool,
1731    dispatch_mode: DispatchMode,
1732) -> String {
1733    let agent_list: String = agents
1734        .iter()
1735        .map(|(name, desc, tools)| {
1736            if tools.is_empty() {
1737                format!("- **{name}**: {desc}\n  Tools: (none)")
1738            } else {
1739                format!("- **{name}**: {desc}\n  Tools: {}", tools.join(", "))
1740            }
1741        })
1742        .collect::<Vec<_>>()
1743        .join("\n");
1744
1745    let delegation_instructions = match (squads_enabled, dispatch_mode) {
1746        (_, DispatchMode::Sequential) => {
1747            "## Delegation Tool\n\
1748             Delegate to ONE agent at a time using **delegate_task**. Wait for the result \
1749             before deciding the next agent. Do NOT batch multiple agents in a single call."
1750        }
1751        (true, DispatchMode::Parallel) => {
1752            "## Delegation Tools\n\
1753             You have two delegation tools:\n\n\
1754             1. **delegate_task** — Run independent subtasks in parallel. Each agent works in \
1755                isolation and cannot see other agents' output. Use when subtasks are independent.\n\n\
1756             2. **form_squad** — Run subtasks in parallel with a shared blackboard. \
1757                Unlike delegate_task, agents can read each other's results via the blackboard. \
1758                Agents run concurrently — use when they benefit from shared state, not when \
1759                strict ordering is needed.\n\n\
1760             After receiving results, synthesize them into a coherent response."
1761        }
1762        (false, DispatchMode::Parallel) => {
1763            "## Delegation Tool\n\
1764             Use the **delegate_task** tool to assign work to sub-agents. You can assign \
1765             multiple tasks at once for parallel execution. Each agent works in isolation. \
1766             After receiving results, synthesize them into a coherent response."
1767        }
1768    };
1769
1770    let choose_tool_step = match (squads_enabled, dispatch_mode) {
1771        (_, DispatchMode::Sequential) => {
1772            "3. DELEGATE: Use delegate_task with ONE agent at a time. Wait for results before \
1773                delegating to the next agent."
1774        }
1775        (true, DispatchMode::Parallel) => {
1776            "3. CHOOSE TOOL: Select delegate_task for independent parallel work, or form_squad \
1777                when agents benefit from shared state via a blackboard."
1778        }
1779        (false, DispatchMode::Parallel) => {
1780            "3. DELEGATE: Use delegate_task to assign subtasks to the best-fit agents."
1781        }
1782    };
1783
1784    format!(
1785        "You are an orchestrator agent. Analyze incoming tasks and delegate work to \
1786         specialized sub-agents.\n\n\
1787         ## Decision Process\n\
1788         1. DECOMPOSE: Break the task into distinct subtasks. Identify which require different expertise.\n\
1789         2. MATCH: For each subtask, pick the best-fit agent based on their description and tools.\n\
1790         {choose_tool_step}\n\n\
1791         ## Important\n\
1792         - ALWAYS delegate to a sub-agent using your delegation tools. You do NOT have any \
1793           direct capabilities — sub-agents have the tools. Never respond to the user directly \
1794           without delegating first.\n\n\
1795         ## Effort Scaling\n\
1796         - If only ONE agent is relevant, delegate a single task. Do NOT force-split across agents.\n\
1797         - If the task is simple enough for one agent, use one agent.\n\
1798         - Only use multiple agents when the task genuinely has multiple distinct parts \
1799           needing different expertise.\n\n\
1800         ## Task Quality\n\
1801         - Each delegated task must be self-contained: include all context the agent needs.\n\
1802         - Be specific: \"Read /path/to/file and extract X\" not \"look at the project\".\n\
1803         - Avoid overlapping tasks — no two agents should do the same work.\n\n\
1804         ## Available Sub-Agents\n\
1805         Choose agents based on their description and available tools:\n\
1806         {agent_list}\n\n\
1807         {delegation_instructions}"
1808    )
1809}
1810
1811/// Direct tool calls on one user request before the entry agent's runner
1812/// injects the deterministic delegation nudge. The entry prompt defines a
1813/// "small focused change" as ~1–3 calls; by 8, the work is substantive.
1814const ENTRY_DELEGATION_NUDGE_AFTER: u32 = 8;
1815
1816/// System prompt for the UNIFIED entry agent (option C).
1817///
1818/// Unlike [`build_system_prompt`] — a pure router that MUST always delegate and
1819/// is forbidden from answering directly — the entry agent holds its own direct
1820/// tools and decides per request: answer directly, do the work itself,
1821/// delegate to specialists, or launch a workflow recipe. This is the
1822/// emergent-routing contract (the LLM routes by choosing tools).
1823pub fn build_entry_agent_prompt(
1824    agents: &[(&str, &str, &[String])],
1825    squads_enabled: bool,
1826    dispatch_mode: DispatchMode,
1827    workflow_recipes: &[(&str, &str)],
1828) -> String {
1829    build_entry_agent_prompt_ext(
1830        agents,
1831        squads_enabled,
1832        dispatch_mode,
1833        workflow_recipes,
1834        EntryCaps::default(),
1835    )
1836}
1837
1838/// Capability flags tailoring the entry prompt to the tools ACTUALLY
1839/// registered — the prompt must never instruct calling a tool the agent
1840/// doesn't have.
1841#[derive(Clone, Copy, Debug, Default)]
1842pub struct EntryCaps {
1843    /// `question` tool registered → include the intake clarify rule.
1844    pub ask_user: bool,
1845    /// `set_goal` tool registered → include the goal-installation rule.
1846    pub set_goal: bool,
1847    /// `set_scope` tool registered → include the scope-declaration rule.
1848    pub set_scope: bool,
1849}
1850
1851/// Like [`build_entry_agent_prompt`], with [`EntryCaps`] gating the
1852/// clarify/completion-discipline rules on tool availability.
1853pub fn build_entry_agent_prompt_ext(
1854    agents: &[(&str, &str, &[String])],
1855    squads_enabled: bool,
1856    dispatch_mode: DispatchMode,
1857    workflow_recipes: &[(&str, &str)],
1858    caps: EntryCaps,
1859) -> String {
1860    let agent_list: String = if agents.is_empty() {
1861        "(none configured — handle everything yourself with your own tools)".to_string()
1862    } else {
1863        agents
1864            .iter()
1865            .map(|(name, desc, tools)| {
1866                if tools.is_empty() {
1867                    format!("- **{name}**: {desc}\n  Tools: (none)")
1868                } else {
1869                    format!("- **{name}**: {desc}\n  Tools: {}", tools.join(", "))
1870                }
1871            })
1872            .collect::<Vec<_>>()
1873            .join("\n")
1874    };
1875
1876    let delegation_line = match (squads_enabled, dispatch_mode) {
1877        (_, DispatchMode::Sequential) => {
1878            "delegate ONE specialist at a time with **delegate_task** (wait for each result)."
1879        }
1880        (true, DispatchMode::Parallel) => {
1881            "delegate independent parts with **delegate_task** (isolated, parallel) or \
1882             **form_squad** (parallel + shared blackboard), then synthesize."
1883        }
1884        (false, DispatchMode::Parallel) => {
1885            "delegate independent parts with **delegate_task** (parallel), then synthesize."
1886        }
1887    };
1888
1889    let (workflow_decision_line, workflow_block) = if workflow_recipes.is_empty() {
1890        (String::new(), String::new())
1891    } else {
1892        let list = workflow_recipes
1893            .iter()
1894            .map(|(name, desc)| format!("- **{name}**: {desc}"))
1895            .collect::<Vec<_>>()
1896            .join("\n");
1897        (
1898            "\n         - **A structured repeatable job matching a recipe**: use run_workflow \
1899             (see below)."
1900                .to_string(),
1901            format!(
1902                "\n\n## Workflow Recipes\n\
1903                 For a structured, repeatable, multi-step job, launch a recipe with \
1904                 **run_workflow** (give the recipe name + args). Available recipes:\n{list}"
1905            ),
1906        )
1907    };
1908
1909    let mut discipline = String::new();
1910    if caps.set_goal {
1911        discipline.push_str(
1912            "\n- **Substantive work gets a judged goal.** Right after planning, install the \
1913             acceptance criteria as your completion goal with `set_goal` (one observable \
1914             criterion per line — e.g. straight from the `intake` recipe's output). An \
1915             independent judge then gates your completion until the criteria are demonstrably \
1916             met — finish by SHOWING the evidence, not claiming it.\n",
1917        );
1918    }
1919    if caps.set_scope {
1920        discipline.push_str(
1921            "\n- **Declare your blast radius.** Before substantive edits, declare the \
1922             files/directories you intend to modify with `set_scope`; edits outside it are \
1923             denied. The file tools can ONLY write INSIDE the workspace, so 'a temporary \
1924             directory' means a fresh scratch SUBDIRECTORY inside the workspace (e.g. \
1925             ./scratch-<name>) — NOT /tmp or your home directory (those are rejected), and \
1926             NOT scattered among tracked files. Keep it gitignored so it stays disposable. \
1927             Widen the scope EXPLICITLY (set_scope again) when genuinely needed — never \
1928             drift into unrelated files.\n",
1929        );
1930    }
1931    let clarify_block = if caps.ask_user {
1932        "\n- **Underspecified request?** Before substantive work, if the next step depends on \
1933         user INTENT and guessing would cause rework (scope / risk / intent — e.g. which \
1934         behavior, destructive vs additive, which of two designs), ask the user with the \
1935         `question` tool: 1–4 batched questions, 2–4 concrete options each. Use the `question` \
1936         TOOL — not a free-text question in your reply — whenever the choices are enumerable \
1937         (the user answers with one keypress). Otherwise PROCEED and state your assumptions \
1938         explicitly (as todos when planning). Never ask about what you can discover yourself \
1939         from the code. NEVER re-ask anything the user ALREADY specified in the request (a \
1940         location like 'temporary directory', a language, a scope) — honor it, don't \
1941         re-litigate it. Resolve a constraint to where the file tools can actually act: 'a \
1942         temporary directory' is honored by a fresh gitignored scratch subdirectory INSIDE \
1943         the workspace (e.g. ./scratch-<name>), since paths outside the workspace are \
1944         rejected — state that assumption and proceed, don't ask.\n"
1945    } else {
1946        ""
1947    };
1948    format!(
1949        "You are Heartbit, a software-engineering lead in a terminal UI. You have your OWN tools \
1950         (read, search, edit, run code) for quick work, AND a team of specialist sub-agents you \
1951         delegate substantive work to. Match the response to the request:\n\n\
1952         ## How to decide (per request)\n\
1953         - **Conversational / simple questions** (greetings, \"what can you do\", a fact, an \
1954           explanation): answer DIRECTLY in prose. No tools, no delegation.\n\
1955         - **A SMALL, focused change you can finish in ~1–3 tool calls** (read one file, one edit, \
1956           run one command): do it YOURSELF.\n\
1957         - **A SUBSTANTIVE task — DELEGATE (this is the DEFAULT for real work).** Triggers: it \
1958           spans MULTIPLE files/areas/components; it has several independent parts; or it is a \
1959           broad request (implement a feature, add+wire+test something, review/audit/refactor \
1960           across the codebase, investigate-then-change). Break it into self-contained tasks and \
1961           {delegation_line} Parallel delegation is ONLY for tasks with NO shared write target AND \
1962           where no task needs another's output — delegated agents run ISOLATED and cannot see \
1963           each other's files. INTERDEPENDENT or shared-output work (e.g. building one project: \
1964           the manifest needs the directory, modules need the crate) must be done by YOU directly \
1965           or as ONE sequential task — never fanned out to N parallel same-agent tasks (they race \
1966           and thrash).{workflow_decision_line}\n{clarify_block}{discipline}\n\
1967         ## Principles\n\
1968         - Trivial → answer. Small & focused → do it yourself. Substantive or multi-part → \
1969           DELEGATE. Do NOT grind through a large multi-part task entirely yourself when \
1970           independent parts could run in parallel across the squad.\n\
1971         - When you do change code yourself, make the smallest correct change and verify it. Be \
1972           concise; show your work through tool use rather than narrating it.\n\
1973         - Each delegated task must be self-contained (include all the context the agent needs).\n\n\
1974         ## Available Sub-Agents\n{agent_list}{workflow_block}"
1975    )
1976}
1977
1978/// Build the delegate_task tool definition.
1979///
1980/// Shared between standalone and Restate paths. Takes `(name, description, tool_names)` triples.
1981///
1982/// When `dispatch_mode` is `Sequential`, the schema adds `maxItems: 1` to the tasks array
1983/// so the LLM can only dispatch one agent at a time. This is a schema-level enforcement
1984/// that works even with weaker models that ignore prompt instructions.
1985pub fn build_delegate_tool_schema(
1986    agents: &[(&str, &str, &[String])],
1987    dispatch_mode: DispatchMode,
1988) -> ToolDefinition {
1989    let agent_descriptions: Vec<serde_json::Value> = agents
1990        .iter()
1991        .map(|(name, desc, tools)| json!({"name": name, "description": desc, "tools": tools}))
1992        .collect();
1993
1994    let (description, tasks_schema) = match dispatch_mode {
1995        DispatchMode::Sequential => (
1996            format!(
1997                "Delegate a task to ONE sub-agent at a time. Wait for the result before \
1998                 delegating to the next agent. Each task runs in isolation. \
1999                 Write clear, self-contained task descriptions with all necessary context. \
2000                 Available agents: {}",
2001                serde_json::to_string(&agent_descriptions)
2002                    .expect("agent list serialization is infallible")
2003            ),
2004            json!({
2005                "type": "array",
2006                "items": {
2007                    "type": "object",
2008                    "properties": {
2009                        "agent": {
2010                            "type": "string",
2011                            "description": "Name of the sub-agent"
2012                        },
2013                        "task": {
2014                            "type": "string",
2015                            "description": "Task instruction for the sub-agent"
2016                        }
2017                    },
2018                    "required": ["agent", "task"]
2019                },
2020                "minItems": 1,
2021                "maxItems": 1
2022            }),
2023        ),
2024        DispatchMode::Parallel => (
2025            format!(
2026                "Delegate independent tasks to sub-agents for parallel execution. \
2027                 Each task runs in isolation — agents cannot see each other's work. \
2028                 ONLY for tasks with NO shared write target and NO cross-task dependency: \
2029                 do NOT split ONE artifact (e.g. files under a shared directory, or steps where \
2030                 one needs another's output) across parallel tasks — build interdependent work \
2031                 as a SINGLE task or do it yourself. \
2032                 Write clear, self-contained task descriptions with all necessary context. \
2033                 Available agents: {}",
2034                serde_json::to_string(&agent_descriptions)
2035                    .expect("agent list serialization is infallible")
2036            ),
2037            json!({
2038                "type": "array",
2039                "items": {
2040                    "type": "object",
2041                    "properties": {
2042                        "agent": {
2043                            "type": "string",
2044                            "description": "Name of the sub-agent"
2045                        },
2046                        "task": {
2047                            "type": "string",
2048                            "description": "Task instruction for the sub-agent"
2049                        }
2050                    },
2051                    "required": ["agent", "task"]
2052                },
2053                "minItems": 1
2054            }),
2055        ),
2056    };
2057
2058    ToolDefinition {
2059        name: "delegate_task".into(),
2060        description,
2061        input_schema: json!({
2062            "type": "object",
2063            "properties": {
2064                "tasks": tasks_schema
2065            },
2066            "required": ["tasks"]
2067        }),
2068    }
2069}
2070
2071/// Build the form_squad tool definition.
2072///
2073/// Standalone path only. Takes `(name, description, tool_names)` triples so the LLM
2074/// knows which agents are available for squad formation.
2075///
2076/// Uses the same `{tasks:[{agent, task}]}` format as `delegate_task` for consistency.
2077pub(crate) fn build_form_squad_tool_schema(agents: &[(&str, &str, &[String])]) -> ToolDefinition {
2078    let agent_descriptions: Vec<serde_json::Value> = agents
2079        .iter()
2080        .map(|(name, desc, tools)| json!({"name": name, "description": desc, "tools": tools}))
2081        .collect();
2082
2083    ToolDefinition {
2084        name: "form_squad".into(),
2085        description: format!(
2086            "Dispatch per-agent tasks in parallel with a shared blackboard for intra-squad coordination. \
2087             Unlike delegate_task, squad agents can read each other's results via the blackboard. \
2088             Use this when agents benefit from shared state (e.g., building on each other's work, \
2089             coordinating on a shared artifact). Agents run concurrently. \
2090             Requires at least 2 tasks (one per agent). \
2091             Available agents: {}",
2092            serde_json::to_string(&agent_descriptions)
2093                .expect("agent list serialization is infallible")
2094        ),
2095        input_schema: json!({
2096            "type": "object",
2097            "properties": {
2098                "tasks": {
2099                    "type": "array",
2100                    "items": {
2101                        "type": "object",
2102                        "properties": {
2103                            "agent": {
2104                                "type": "string",
2105                                "description": "Name of the sub-agent"
2106                            },
2107                            "task": {
2108                                "type": "string",
2109                                "description": "Task instruction for the sub-agent"
2110                            }
2111                        },
2112                        "required": ["agent", "task"]
2113                    },
2114                    "minItems": 2,
2115                    "description": "Per-agent tasks for the squad (minimum 2)"
2116                }
2117            },
2118            "required": ["tasks"]
2119        }),
2120    }
2121}
2122
2123/// Per-sub-agent context-management wiring (multi-agent enablement).
2124///
2125/// All fields are opt-in: [`Default`] reproduces today's behavior exactly. The
2126/// caller creates the stores, builds the sub-agent's tools FROM them, and sets
2127/// the SAME stores here; the orchestrator forwards each to the sub-agent's
2128/// [`AgentRunnerBuilder`](crate::agent::builder::AgentRunnerBuilder). The
2129/// session-prune config that pairs with restore-on-demand reuses the existing
2130/// [`SubAgentConfig::session_prune_config`] (a gentle default is applied when a
2131/// recall store is set but no prune config is).
2132#[derive(Clone, Default)]
2133pub struct SubAgentContextConfig {
2134    /// Shared todo store the sub-agent recites at the context tail each turn
2135    /// (long-horizon recitation). MUST be the same store backing the
2136    /// sub-agent's `todowrite`/`todoread` tools.
2137    pub todo_store: Option<Arc<crate::tool::builtins::TodoStore>>,
2138    /// Per-run recall store for restore-on-demand. MUST be the same store
2139    /// passed into the sub-agent's `BuiltinToolsConfig.context_recall_store`.
2140    pub context_recall_store: Option<Arc<crate::agent::context_recall::ContextRecallStore>>,
2141    /// Model context window (tokens) for the proactive compaction backstop.
2142    pub context_window_tokens: Option<u32>,
2143    /// When true, a RED verify blocks the sub-agent's natural completion
2144    /// (bounded replan).
2145    pub replan_on_verify_fail: bool,
2146}
2147
2148/// Configuration for adding a sub-agent to the orchestrator.
2149///
2150/// Used by `OrchestratorBuilder::sub_agent_full` to avoid a long parameter list.
2151#[derive(Default)]
2152pub struct SubAgentConfig {
2153    /// Unique name for this sub-agent.
2154    pub name: String,
2155    /// Human-readable description of what this sub-agent does.
2156    pub description: String,
2157    /// System prompt to give this sub-agent.
2158    pub system_prompt: String,
2159    /// Tools available to this sub-agent.
2160    pub tools: Vec<Arc<dyn Tool>>,
2161    /// Context window management strategy for this sub-agent.
2162    pub context_strategy: Option<ContextStrategy>,
2163    /// Summarize conversation when message count exceeds this threshold.
2164    pub summarize_threshold: Option<u32>,
2165    /// Per-tool execution timeout for this sub-agent.
2166    pub tool_timeout: Option<Duration>,
2167    /// Maximum tool output size in bytes for this sub-agent.
2168    pub max_tool_output_bytes: Option<usize>,
2169    /// Per-agent turn limit. When `None`, uses orchestrator default.
2170    pub max_turns: Option<usize>,
2171    /// Per-agent token limit. When `None`, uses orchestrator default.
2172    pub max_tokens: Option<u32>,
2173    /// Optional JSON Schema for structured output. When set, the sub-agent
2174    /// receives a synthetic `__respond__` tool and returns structured JSON.
2175    pub response_schema: Option<serde_json::Value>,
2176    /// Optional per-agent run timeout. When `None`, no timeout is applied
2177    /// to this sub-agent's run.
2178    pub run_timeout: Option<Duration>,
2179    /// Guardrails applied to this sub-agent's LLM calls and tool executions.
2180    pub guardrails: Vec<Arc<dyn Guardrail>>,
2181    /// Optional per-agent LLM provider override. When `None`, the sub-agent
2182    /// inherits the orchestrator's provider. Use this to route sub-agents to
2183    /// different models (e.g., Haiku for cheap tasks, Opus for complex ones).
2184    pub provider: Option<Arc<BoxedProvider>>,
2185    /// Optional reasoning/thinking effort level for this sub-agent.
2186    pub reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
2187    /// Enable reflection prompts after tool results for this sub-agent.
2188    pub enable_reflection: Option<bool>,
2189    /// Tool output compression threshold in bytes for this sub-agent.
2190    pub tool_output_compression_threshold: Option<usize>,
2191    /// Maximum tools per turn for this sub-agent.
2192    pub max_tools_per_turn: Option<usize>,
2193    /// Tool profile for pre-filtering tool definitions for this sub-agent.
2194    pub tool_profile: Option<super::tool_filter::ToolProfile>,
2195    /// Maximum consecutive identical tool-call turns for doom loop detection.
2196    pub max_identical_tool_calls: Option<u32>,
2197    /// Maximum consecutive fuzzy-identical tool-call turns for doom loop detection.
2198    pub max_fuzzy_identical_tool_calls: Option<u32>,
2199    /// Maximum number of tool calls allowed in a single LLM turn (per-turn cap).
2200    pub max_tool_calls_per_turn: Option<u32>,
2201    /// Session pruning configuration for this sub-agent.
2202    pub session_prune_config: Option<crate::agent::pruner::SessionPruneConfig>,
2203    /// Enable recursive summarization for this sub-agent.
2204    pub enable_recursive_summarization: Option<bool>,
2205    /// Memory reflection threshold for this sub-agent.
2206    pub reflection_threshold: Option<u32>,
2207    /// Run memory consolidation at session end for this sub-agent.
2208    pub consolidate_on_exit: Option<bool>,
2209    /// Optional workspace root for this sub-agent's file tools and system prompt.
2210    pub workspace: Option<std::path::PathBuf>,
2211    /// Hard limit on cumulative tokens (input + output) across all turns.
2212    pub max_total_tokens: Option<u64>,
2213    /// Optional audit trail for recording untruncated agent decisions.
2214    pub audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
2215    /// Optional user ID for multi-tenant audit enrichment.
2216    pub audit_user_id: Option<String>,
2217    /// Optional tenant ID for multi-tenant audit enrichment.
2218    pub audit_tenant_id: Option<String>,
2219    /// Delegation chain for audit records (propagated to sub-agents).
2220    #[allow(dead_code)]
2221    pub audit_delegation_chain: Vec<String>,
2222    /// Context-management wiring (recitation, restore-on-demand, proactive
2223    /// compaction, replan). Opt-in; `Default` preserves current behavior.
2224    pub context: SubAgentContextConfig,
2225}
2226
2227/// Builder for [`Orchestrator`].
2228///
2229/// Construct with [`Orchestrator::builder`] and call `.build()` to get an `Orchestrator`.
2230/// Builder for [`Orchestrator`].
2231///
2232/// Construct via [`Orchestrator::builder`], register sub-agents with
2233/// [`sub_agent`](OrchestratorBuilder::sub_agent), then call
2234/// [`build`](OrchestratorBuilder::build) to produce an `Orchestrator` ready to
2235/// run. The builder validates that all sub-agent names are unique and rejects
2236/// zero-valued turn/token limits. Sub-agent settings that are not explicitly set
2237/// on the sub-agent inherit the orchestrator-level defaults at build time.
2238pub struct OrchestratorBuilder<P: LlmProvider> {
2239    provider: Arc<P>,
2240    sub_agents: Vec<SubAgentDef>,
2241    max_turns: usize,
2242    max_tokens: u32,
2243    context_strategy: Option<ContextStrategy>,
2244    summarize_threshold: Option<u32>,
2245    tool_timeout: Option<Duration>,
2246    max_tool_output_bytes: Option<usize>,
2247    shared_memory: Option<Arc<dyn Memory>>,
2248    memory_namespace_prefix: Option<String>,
2249    blackboard: Option<Arc<dyn Blackboard>>,
2250    knowledge_base: Option<Arc<dyn KnowledgeBase>>,
2251    on_text: Option<Arc<crate::llm::OnText>>,
2252    on_reasoning: Option<Arc<crate::llm::OnReasoning>>,
2253    on_approval: Option<Arc<crate::llm::OnApproval>>,
2254    on_event: Option<Arc<OnEvent>>,
2255    on_input: Option<Arc<crate::agent::OnInput>>,
2256    interrupt: Option<super::interrupt::InterruptHandle>,
2257    guardrails: Vec<Arc<dyn Guardrail>>,
2258    on_question: Option<Arc<OnQuestion>>,
2259    run_timeout: Option<Duration>,
2260    enable_squads: Option<bool>,
2261    reasoning_effort: Option<crate::llm::types::ReasoningEffort>,
2262    enable_reflection: bool,
2263    tool_output_compression_threshold: Option<usize>,
2264    max_tools_per_turn: Option<usize>,
2265    max_identical_tool_calls: Option<u32>,
2266    max_fuzzy_identical_tool_calls: Option<u32>,
2267    max_tool_calls_per_turn: Option<u32>,
2268    permission_rules: super::permission::PermissionRuleset,
2269    instruction_text: Option<String>,
2270    learned_permissions: Option<Arc<std::sync::Mutex<super::permission::LearnedPermissions>>>,
2271    lsp_manager: Option<Arc<crate::lsp::LspManager>>,
2272    observability_mode: Option<super::observability::ObservabilityMode>,
2273    dispatch_mode: DispatchMode,
2274    /// Optional workspace root for all sub-agents. Propagated to sub-agent builders.
2275    workspace: Option<std::path::PathBuf>,
2276    /// Optional audit trail propagated to all sub-agents.
2277    audit_trail: Option<Arc<dyn super::audit::AuditTrail>>,
2278    /// Optional user ID for multi-tenant audit enrichment (propagated to all sub-agents).
2279    audit_user_id: Option<String>,
2280    /// Optional tenant ID for multi-tenant audit enrichment (propagated to all sub-agents).
2281    audit_tenant_id: Option<String>,
2282    /// Delegation chain for audit records (propagated to all sub-agents).
2283    audit_delegation_chain: Vec<String>,
2284    /// Whether sub-agents are allowed to write to shared institutional memory.
2285    /// Defaults to `true` for backward compatibility. Set to `false` to restrict
2286    /// write access based on user roles.
2287    allow_shared_write: bool,
2288    /// Whether to append the multi-agent collaboration prompt to each sub-agent's
2289    /// system prompt. Default: true.
2290    multi_agent_prompt: bool,
2291    /// Dynamic agent spawning configuration. When `Some`, the `spawn_agent` tool
2292    /// is registered on the orchestrator.
2293    spawn_config: Option<crate::types::SpawnConfig>,
2294    /// Pre-built builtin tools to use as the spawn tool pool.
2295    spawn_builtin_tools: Vec<Arc<dyn Tool>>,
2296    /// Optional per-tenant token tracker propagated to the orchestrator's own runner
2297    /// and to all sub-agent runners dispatched via DelegateTaskTool, FormSquadTool,
2298    /// and SpawnAgentTool. When set, multi-agent token usage is correctly accounted
2299    /// per tenant so the per-tenant cap applies to the combined run.
2300    tenant_tracker: Option<Arc<crate::agent::tenant_tracker::TenantTokenTracker>>,
2301    /// Unified entry-agent mode (option C). When true, the orchestrator's OWN
2302    /// runner uses the non-absolute [`build_entry_agent_prompt`] and is given
2303    /// `entry_direct_tools` so it can answer directly / do simple work itself,
2304    /// in addition to delegating. Default false = pure router (unchanged).
2305    entry_mode: bool,
2306    /// Direct tools for the entry agent's own runner (builtins + MCP + the
2307    /// `run_workflow` tool). Only used when `entry_mode`.
2308    entry_direct_tools: Vec<Arc<dyn Tool>>,
2309    /// `(name, description)` of the workflow recipes advertised in the entry
2310    /// prompt (the `run_workflow` tool itself is supplied via `entry_direct_tools`).
2311    entry_workflow_recipes: Vec<(String, String)>,
2312    /// Context-management wiring for the entry agent's OWN runner (recitation,
2313    /// restore-on-demand, proactive compaction, replan). Opt-in.
2314    entry_context: SubAgentContextConfig,
2315    /// Optional [`GoalCondition`](super::goal::GoalCondition) for the entry
2316    /// agent's own runner: an independent judge gates natural completion
2317    /// (completion-loop harness). Only used when `entry_mode`.
2318    entry_goal: Option<super::goal::GoalCondition>,
2319    /// Optional judge provider enabling the RUNTIME goal path: registers a
2320    /// `set_goal` tool on the entry agent sharing the runner's goal slot, so
2321    /// the agent installs its own acceptance criteria (e.g. from the `intake`
2322    /// recipe) and the judge gates every later stop. Only used when `entry_mode`.
2323    entry_goal_judge: Option<Arc<BoxedProvider>>,
2324    /// Optional request-intent router for the entry agent's own runner
2325    /// (answer/execute/study/clarify mode contracts). Only used when `entry_mode`.
2326    entry_request_router: Option<Arc<super::router::RequestRouter>>,
2327}
2328
2329impl<P: LlmProvider + 'static> OrchestratorBuilder<P> {
2330    /// Turn this orchestrator into the unified **entry agent** (option C): its
2331    /// own runner gets `direct_tools` (builtins/MCP/run_workflow) and a
2332    /// non-absolute prompt, so it answers conversational turns directly, does
2333    /// simple work itself, delegates multi-part work, and launches workflow
2334    /// recipes — deciding per request via tool choice.
2335    pub fn entry_agent(mut self, direct_tools: Vec<Arc<dyn Tool>>) -> Self {
2336        self.entry_mode = true;
2337        self.entry_direct_tools = direct_tools;
2338        self
2339    }
2340
2341    /// Advertise workflow recipes (name + description) in the entry prompt.
2342    pub fn entry_workflow_recipes(mut self, recipes: Vec<(String, String)>) -> Self {
2343        self.entry_workflow_recipes = recipes;
2344        self
2345    }
2346
2347    /// Context-management wiring for the entry agent's own runner.
2348    pub fn entry_context(mut self, context: SubAgentContextConfig) -> Self {
2349        self.entry_context = context;
2350        self
2351    }
2352
2353    /// Set a [`GoalCondition`](super::goal::GoalCondition) on the entry agent's
2354    /// own runner: after it would naturally finish, the independent judge
2355    /// decides whether the objective is met and re-injects guidance when not
2356    /// (bounded by the goal's `max_continuations`).
2357    pub fn entry_goal(mut self, goal: super::goal::GoalCondition) -> Self {
2358        self.entry_goal = Some(goal);
2359        self
2360    }
2361
2362    /// Enable the RUNTIME goal path: give the entry agent a `set_goal` tool
2363    /// (judged by `judge` — use a separate/cheaper model) sharing the runner's
2364    /// goal slot. The agent installs its own acceptance criteria mid-run and
2365    /// the independent judge gates every natural stop until they're met.
2366    pub fn entry_goal_judge(mut self, judge: Arc<BoxedProvider>) -> Self {
2367        self.entry_goal_judge = Some(judge);
2368        self
2369    }
2370
2371    /// Enable the request-intent router on the entry agent's own runner: each
2372    /// fresh user request is routed to a response mode and the harness
2373    /// enforces its contract (see [`super::router::RequestRouter`]).
2374    pub fn entry_request_router(mut self, router: Arc<super::router::RequestRouter>) -> Self {
2375        self.entry_request_router = Some(router);
2376        self
2377    }
2378
2379    /// Add a sub-agent with the given name, description, and system prompt.
2380    pub fn sub_agent(
2381        mut self,
2382        name: impl Into<String>,
2383        description: impl Into<String>,
2384        system_prompt: impl Into<String>,
2385    ) -> Self {
2386        let mut def = SubAgentDef::new(name, description, system_prompt);
2387        def.workspace = self.workspace.clone();
2388        def.audit_trail = self.audit_trail.clone();
2389        def.audit_user_id = self.audit_user_id.clone();
2390        def.audit_tenant_id = self.audit_tenant_id.clone();
2391        def.audit_delegation_chain = self.audit_delegation_chain.clone();
2392        self.sub_agents.push(def);
2393        self
2394    }
2395
2396    /// Add a sub-agent with a predefined set of tools.
2397    pub fn sub_agent_with_tools(
2398        mut self,
2399        name: impl Into<String>,
2400        description: impl Into<String>,
2401        system_prompt: impl Into<String>,
2402        tools: Vec<Arc<dyn Tool>>,
2403    ) -> Self {
2404        let mut def = SubAgentDef::new(name, description, system_prompt);
2405        def.tools = tools;
2406        def.workspace = self.workspace.clone();
2407        def.audit_trail = self.audit_trail.clone();
2408        def.audit_user_id = self.audit_user_id.clone();
2409        def.audit_tenant_id = self.audit_tenant_id.clone();
2410        def.audit_delegation_chain = self.audit_delegation_chain.clone();
2411        self.sub_agents.push(def);
2412        self
2413    }
2414
2415    /// Add a sub-agent using a fully specified [`SubAgentConfig`].
2416    pub fn sub_agent_full(mut self, config: SubAgentConfig) -> Self {
2417        let mut def = SubAgentDef::from(config);
2418        if def.workspace.is_none() {
2419            def.workspace = self.workspace.clone();
2420        }
2421        if def.audit_trail.is_none() {
2422            def.audit_trail = self.audit_trail.clone();
2423        }
2424        if def.audit_user_id.is_none() {
2425            def.audit_user_id = self.audit_user_id.clone();
2426        }
2427        if def.audit_tenant_id.is_none() {
2428            def.audit_tenant_id = self.audit_tenant_id.clone();
2429        }
2430        if def.audit_delegation_chain.is_empty() {
2431            def.audit_delegation_chain = self.audit_delegation_chain.clone();
2432        }
2433        self.sub_agents.push(def);
2434        self
2435    }
2436
2437    /// Set the maximum number of turns for the orchestrator's own LLM loop.
2438    pub fn max_turns(mut self, max_turns: usize) -> Self {
2439        self.max_turns = max_turns;
2440        self
2441    }
2442
2443    /// Set the maximum number of tokens for the orchestrator's own LLM calls.
2444    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
2445        self.max_tokens = max_tokens;
2446        self
2447    }
2448
2449    /// Set context management strategy for the orchestrator's own conversation.
2450    pub fn context_strategy(mut self, strategy: ContextStrategy) -> Self {
2451        self.context_strategy = Some(strategy);
2452        self
2453    }
2454
2455    /// Set token threshold for summarization of the orchestrator's own context.
2456    pub fn summarize_threshold(mut self, threshold: u32) -> Self {
2457        self.summarize_threshold = Some(threshold);
2458        self
2459    }
2460
2461    /// Set timeout for the orchestrator's own tool executions (i.e., delegate_task).
2462    pub fn tool_timeout(mut self, timeout: Duration) -> Self {
2463        self.tool_timeout = Some(timeout);
2464        self
2465    }
2466
2467    /// Set maximum byte size for tool output on the orchestrator's own tools.
2468    pub fn max_tool_output_bytes(mut self, max: usize) -> Self {
2469        self.max_tool_output_bytes = Some(max);
2470        self
2471    }
2472
2473    /// Attach a shared memory store. Each sub-agent gets:
2474    /// - Private memory tools (namespaced to the agent)
2475    /// - Shared memory tools (cross-agent read/write)
2476    pub fn shared_memory(mut self, memory: Arc<dyn Memory>) -> Self {
2477        self.shared_memory = Some(memory);
2478        self
2479    }
2480
2481    /// Set a prefix for memory namespacing. When set, sub-agent memory
2482    /// namespaces become `"{prefix}:{agent_name}"` instead of just `"{agent_name}"`.
2483    /// This enables per-user or per-story isolation without nesting `NamespacedMemory`.
2484    pub fn memory_namespace_prefix(mut self, prefix: impl Into<String>) -> Self {
2485        self.memory_namespace_prefix = Some(prefix.into());
2486        self
2487    }
2488
2489    /// Control whether sub-agents are allowed to write to shared institutional memory.
2490    ///
2491    /// When `false`, only `shared_memory_read` is provided — agents can read company
2492    /// knowledge but cannot write to it. Use this to enforce role-based write access.
2493    /// Defaults to `true` for backward compatibility.
2494    pub fn allow_shared_write(mut self, allow: bool) -> Self {
2495        self.allow_shared_write = allow;
2496        self
2497    }
2498
2499    /// Enable or disable the multi-agent collaboration prompt on sub-agent
2500    /// system prompts. Default: `true`.
2501    pub fn multi_agent_prompt(mut self, enabled: bool) -> Self {
2502        self.multi_agent_prompt = enabled;
2503        self
2504    }
2505
2506    /// Enable dynamic agent spawning with the given config.
2507    ///
2508    /// The `builtin_tools` are the pool from which spawned agents' tools are selected
2509    /// (filtered by `SpawnConfig::tool_allowlist`). Unknown tools in the allowlist
2510    /// cause a build error.
2511    pub fn spawn_config(
2512        mut self,
2513        config: crate::types::SpawnConfig,
2514        builtin_tools: Vec<Arc<dyn Tool>>,
2515    ) -> Self {
2516        self.spawn_config = Some(config);
2517        self.spawn_builtin_tools = builtin_tools;
2518        self
2519    }
2520
2521    /// Attach a per-tenant token tracker to the orchestrator and all sub-agents.
2522    ///
2523    /// When set, the orchestrator's own runner and every sub-agent runner dispatched
2524    /// via `delegate_task`, `form_squad`, or `spawn_agent` inherits the same tracker.
2525    /// This ensures multi-agent token usage is correctly accounted per tenant so the
2526    /// per-tenant cap applies to the combined run. Requires `audit_user_context` to
2527    /// also be set — the tracker only fires when both a tracker and a tenant ID are
2528    /// present.
2529    pub fn tenant_tracker(
2530        mut self,
2531        tracker: Arc<crate::agent::tenant_tracker::TenantTokenTracker>,
2532    ) -> Self {
2533        self.tenant_tracker = Some(tracker);
2534        self
2535    }
2536
2537    /// Attach a shared blackboard for cross-agent coordination.
2538    ///
2539    /// Each sub-agent receives `blackboard_read`, `blackboard_write`, and
2540    /// `blackboard_list` tools. After each sub-agent completes, its result
2541    /// is automatically written to the blackboard under the `"agent:{name}"` key.
2542    pub fn blackboard(mut self, blackboard: Arc<dyn Blackboard>) -> Self {
2543        self.blackboard = Some(blackboard);
2544        self
2545    }
2546
2547    /// Attach a shared knowledge base for document retrieval.
2548    ///
2549    /// Each sub-agent receives a `knowledge_search` tool to query the knowledge
2550    /// base at runtime.
2551    pub fn knowledge(mut self, kb: Arc<dyn KnowledgeBase>) -> Self {
2552        self.knowledge_base = Some(kb);
2553        self
2554    }
2555
2556    /// Set a callback for streaming text output on the orchestrator's LLM calls.
2557    /// Sub-agents do not stream — only the orchestrator's own reasoning and
2558    /// final synthesis are emitted incrementally.
2559    pub fn on_text(mut self, callback: Arc<crate::llm::OnText>) -> Self {
2560        self.on_text = Some(callback);
2561        self
2562    }
2563
2564    /// Set a callback for streaming reasoning (chain-of-thought) on the
2565    /// orchestrator's LLM calls, forwarded to its own runner alongside `on_text`.
2566    pub fn on_reasoning(mut self, callback: Arc<crate::llm::OnReasoning>) -> Self {
2567        self.on_reasoning = Some(callback);
2568        self
2569    }
2570
2571    /// Set a callback for human-in-the-loop approval on tool calls.
2572    ///
2573    /// The callback gates the orchestrator's own tool calls (delegate_task,
2574    /// form_squad, spawn_agent, direct tools) AND is propagated to every
2575    /// sub-agent runner those tools spawn, so delegated work runs under the
2576    /// same human gate (audit 2026-06-09 — without propagation, one approved
2577    /// `delegate_task` call silently dropped the gate for all sub-agent work).
2578    /// `AlwaysAllow`/`AlwaysDeny` decisions share one learned-permission store
2579    /// across the whole run when [`learned_permissions`] is set.
2580    ///
2581    /// [`learned_permissions`]: OrchestratorBuilder::learned_permissions
2582    pub fn on_approval(mut self, callback: Arc<crate::llm::OnApproval>) -> Self {
2583        self.on_approval = Some(callback);
2584        self
2585    }
2586
2587    /// Set the `on_input` callback for interactive multi-turn sessions. Forwarded
2588    /// to the orchestrator's inner runner: when the orchestrator finishes a turn
2589    /// with no tool calls, it awaits the next message (history preserved), exactly
2590    /// like a single [`AgentRunner`]. A single [`run`](Orchestrator::run) then
2591    /// drives the whole multi-turn session.
2592    pub fn on_input(mut self, callback: Arc<crate::agent::OnInput>) -> Self {
2593        self.on_input = Some(callback);
2594        self
2595    }
2596
2597    /// Set a per-turn [`InterruptHandle`](super::interrupt::InterruptHandle),
2598    /// forwarded to the inner runner so a caller can abandon the current turn
2599    /// (LLM generation or tool/delegation batch) without ending the session.
2600    pub fn interrupt(mut self, handle: super::interrupt::InterruptHandle) -> Self {
2601        self.interrupt = Some(handle);
2602        self
2603    }
2604
2605    /// Set learned permissions for persisting AlwaysAllow/AlwaysDeny decisions.
2606    pub fn learned_permissions(
2607        mut self,
2608        learned: Arc<std::sync::Mutex<super::permission::LearnedPermissions>>,
2609    ) -> Self {
2610        self.learned_permissions = Some(learned);
2611        self
2612    }
2613
2614    /// Set an LSP manager for collecting diagnostics after file-modifying tools.
2615    pub fn lsp_manager(mut self, manager: Arc<crate::lsp::LspManager>) -> Self {
2616        self.lsp_manager = Some(manager);
2617        self
2618    }
2619
2620    /// Set a callback for structured agent events.
2621    ///
2622    /// The callback receives events from the orchestrator's own agent loop **and**
2623    /// from all sub-agents (both `delegate_task` and `form_squad` paths). Sub-agent
2624    /// events carry the sub-agent name in their `agent` field for disambiguation.
2625    pub fn on_event(mut self, callback: Arc<OnEvent>) -> Self {
2626        self.on_event = Some(callback);
2627        self
2628    }
2629
2630    /// Add a single guardrail applied to the orchestrator's own agent loop.
2631    pub fn guardrail(mut self, guardrail: Arc<dyn Guardrail>) -> Self {
2632        self.guardrails.push(guardrail);
2633        self
2634    }
2635
2636    /// Add multiple guardrails to the orchestrator's own agent loop.
2637    pub fn guardrails(mut self, guardrails: Vec<Arc<dyn Guardrail>>) -> Self {
2638        self.guardrails.extend(guardrails);
2639        self
2640    }
2641
2642    /// Set a callback for structured questions from the orchestrator to the user.
2643    pub fn on_question(mut self, callback: Arc<OnQuestion>) -> Self {
2644        self.on_question = Some(callback);
2645        self
2646    }
2647
2648    /// Set a wall-clock deadline for the entire orchestrator run. If the run
2649    /// does not complete within this duration, `Error::RunTimeout` is returned.
2650    pub fn run_timeout(mut self, timeout: Duration) -> Self {
2651        self.run_timeout = Some(timeout);
2652        self
2653    }
2654
2655    /// Enable or disable the `form_squad` tool for dynamic agent squad formation.
2656    ///
2657    /// When `true`, the orchestrator can assemble temporary squads of agents to
2658    /// collaboratively solve complex subtasks. When `false`, only `delegate_task`
2659    /// is available.
2660    ///
2661    /// Default: auto-enabled when there are >= 2 agents.
2662    pub fn enable_squads(mut self, enable: bool) -> Self {
2663        self.enable_squads = Some(enable);
2664        self
2665    }
2666
2667    /// Set reasoning/thinking effort level for the orchestrator's own LLM calls.
2668    pub fn reasoning_effort(mut self, effort: crate::llm::types::ReasoningEffort) -> Self {
2669        self.reasoning_effort = Some(effort);
2670        self
2671    }
2672
2673    /// Enable or disable reflection prompts after tool results.
2674    pub fn enable_reflection(mut self, enabled: bool) -> Self {
2675        self.enable_reflection = enabled;
2676        self
2677    }
2678
2679    /// Compress tool outputs larger than `threshold` bytes before including in context.
2680    pub fn tool_output_compression_threshold(mut self, threshold: usize) -> Self {
2681        self.tool_output_compression_threshold = Some(threshold);
2682        self
2683    }
2684
2685    /// Limit the number of tools the LLM may call in a single turn.
2686    pub fn max_tools_per_turn(mut self, max: usize) -> Self {
2687        self.max_tools_per_turn = Some(max);
2688        self
2689    }
2690
2691    /// Set the doom-loop threshold: abort after this many consecutive identical tool-call batches.
2692    pub fn max_identical_tool_calls(mut self, max: u32) -> Self {
2693        self.max_identical_tool_calls = Some(max);
2694        self
2695    }
2696
2697    /// Set the fuzzy doom-loop threshold for near-duplicate tool-call batches.
2698    pub fn max_fuzzy_identical_tool_calls(mut self, max: u32) -> Self {
2699        self.max_fuzzy_identical_tool_calls = Some(max);
2700        self
2701    }
2702
2703    /// Cap the total number of tool calls the LLM may make in a single turn.
2704    pub fn max_tool_calls_per_turn(mut self, cap: u32) -> Self {
2705        self.max_tool_calls_per_turn = Some(cap);
2706        self
2707    }
2708
2709    /// Set the permission ruleset applied to tool calls made by the orchestrator's own runner.
2710    pub fn permission_rules(mut self, rules: super::permission::PermissionRuleset) -> Self {
2711        self.permission_rules = rules;
2712        self
2713    }
2714
2715    /// Provide pre-loaded instruction text to prepend to the orchestrator's system prompt.
2716    pub fn instruction_text(mut self, text: impl Into<String>) -> Self {
2717        let text = text.into();
2718        if !text.is_empty() {
2719            self.instruction_text = Some(text);
2720        }
2721        self
2722    }
2723
2724    /// Set the observability verbosity mode for the orchestrator and its sub-agents.
2725    pub fn observability_mode(mut self, mode: super::observability::ObservabilityMode) -> Self {
2726        self.observability_mode = Some(mode);
2727        self
2728    }
2729
2730    /// Set the dispatch mode for orchestrator delegation.
2731    ///
2732    /// When `Sequential`, the delegate_task schema constrains `maxItems: 1` so
2733    /// the LLM dispatches one agent at a time. This is enforced at the JSON schema
2734    /// level, which works even with weaker models that ignore prompt instructions.
2735    pub fn dispatch_mode(mut self, mode: DispatchMode) -> Self {
2736        self.dispatch_mode = mode;
2737        self
2738    }
2739
2740    /// Set the workspace directory for all sub-agents. Each sub-agent
2741    /// shares this workspace unless overridden via `SubAgentConfig.workspace`.
2742    pub fn workspace(mut self, path: impl Into<std::path::PathBuf>) -> Self {
2743        self.workspace = Some(path.into());
2744        self
2745    }
2746
2747    /// Attach an audit trail propagated to all sub-agents.
2748    ///
2749    /// Individual sub-agents can override via `SubAgentConfig.audit_trail`.
2750    pub fn audit_trail(mut self, trail: Arc<dyn super::audit::AuditTrail>) -> Self {
2751        self.audit_trail = Some(trail);
2752        self
2753    }
2754
2755    /// Set user context for multi-tenant audit enrichment.
2756    /// Propagated to all sub-agents and the orchestrator's own runner.
2757    pub fn audit_user_context(
2758        mut self,
2759        user_id: impl Into<String>,
2760        tenant_id: impl Into<String>,
2761    ) -> Self {
2762        self.audit_user_id = Some(user_id.into());
2763        self.audit_tenant_id = Some(tenant_id.into());
2764        self
2765    }
2766
2767    /// Set the delegation chain for all sub-agent audit records.
2768    pub fn audit_delegation_chain(mut self, chain: Vec<String>) -> Self {
2769        self.audit_delegation_chain = chain;
2770        self
2771    }
2772
2773    /// Build the [`Orchestrator`], validating all sub-agent definitions.
2774    pub fn build(mut self) -> Result<Orchestrator<P>, Error> {
2775        // Append multi-agent collaboration prompt to each sub-agent's system prompt
2776        if self.multi_agent_prompt {
2777            for agent in &mut self.sub_agents {
2778                agent
2779                    .system_prompt
2780                    .push_str(&crate::agent::prompts::render_collab_prompt(
2781                        &agent.name,
2782                        &agent.description,
2783                    ));
2784            }
2785        }
2786
2787        // Validate sub-agent definitions
2788        {
2789            let mut seen = std::collections::HashSet::new();
2790            for agent in &self.sub_agents {
2791                if agent.name.is_empty() {
2792                    return Err(Error::Config("sub-agent name must not be empty".into()));
2793                }
2794                if !seen.insert(&agent.name) {
2795                    return Err(Error::Config(format!(
2796                        "duplicate sub-agent name: '{}'",
2797                        agent.name
2798                    )));
2799                }
2800                if agent.max_turns == Some(0) {
2801                    return Err(Error::Config(format!(
2802                        "sub-agent '{}': max_turns must be > 0",
2803                        agent.name
2804                    )));
2805                }
2806                if agent.max_tokens == Some(0) {
2807                    return Err(Error::Config(format!(
2808                        "sub-agent '{}': max_tokens must be > 0",
2809                        agent.name
2810                    )));
2811                }
2812            }
2813        }
2814
2815        if self.sub_agents.is_empty() {
2816            tracing::warn!(
2817                "orchestrator built with no sub-agents — delegate_task tool will list no agents"
2818            );
2819        }
2820
2821        // Sequential dispatch and squads are incompatible — form_squad runs agents
2822        // in parallel, which defeats sequential ordering. Force squads off.
2823        let squads_enabled = if self.dispatch_mode == DispatchMode::Sequential {
2824            false
2825        } else {
2826            self.enable_squads.unwrap_or(self.sub_agents.len() >= 2)
2827        };
2828        if squads_enabled && self.sub_agents.len() < 2 {
2829            tracing::warn!(
2830                "enable_squads is true but fewer than 2 agents are registered — \
2831                 form_squad requires at least 2 agents to be useful"
2832            );
2833        }
2834
2835        let tool_names: Vec<Vec<String>> = self
2836            .sub_agents
2837            .iter()
2838            .map(|a| a.tools.iter().map(|t| t.definition().name).collect())
2839            .collect();
2840        let triples: Vec<(&str, &str, &[String])> = self
2841            .sub_agents
2842            .iter()
2843            .zip(tool_names.iter())
2844            .map(|(a, names)| (a.name.as_str(), a.description.as_str(), names.as_slice()))
2845            .collect();
2846        let mut system = if self.entry_mode {
2847            // Unified entry agent (option C): non-absolute prompt that lets the
2848            // agent answer directly / do simple work / delegate / run a workflow.
2849            let recipes: Vec<(&str, &str)> = self
2850                .entry_workflow_recipes
2851                .iter()
2852                .map(|(n, d)| (n.as_str(), d.as_str()))
2853                .collect();
2854            // Gate each rule on the tool ACTUALLY being available (P6,
2855            // completion-loop): question from the direct tools; set_goal from
2856            // the judge config (the tool is registered below); set_scope from
2857            // the direct tools.
2858            let caps = EntryCaps {
2859                ask_user: self
2860                    .entry_direct_tools
2861                    .iter()
2862                    .any(|t| t.definition().name == "question"),
2863                set_goal: self.entry_goal_judge.is_some(),
2864                set_scope: self
2865                    .entry_direct_tools
2866                    .iter()
2867                    .any(|t| t.definition().name == "set_scope"),
2868            };
2869            build_entry_agent_prompt_ext(
2870                &triples,
2871                squads_enabled,
2872                self.dispatch_mode,
2873                &recipes,
2874                caps,
2875            )
2876        } else {
2877            build_system_prompt(&triples, squads_enabled, self.dispatch_mode)
2878        };
2879        // Append user identity context so the orchestrator knows who it is serving.
2880        if let (Some(uid), Some(tid)) = (&self.audit_user_id, &self.audit_tenant_id) {
2881            system.push_str(&format!(
2882                "\n---\nYou are operating on behalf of **{uid}** in organization **{tid}**.\nKeep this user's information private. Do not share their data with other users."
2883            ));
2884        }
2885        let cached_definition = build_delegate_tool_schema(&triples, self.dispatch_mode);
2886        let form_squad_definition = if squads_enabled {
2887            Some(build_form_squad_tool_schema(&triples))
2888        } else {
2889            None
2890        };
2891        // Drop borrows on self.sub_agents so we can move it below
2892        drop(triples);
2893        drop(tool_names);
2894
2895        let sub_agent_tokens = Arc::new(Mutex::new(TokenUsage::default()));
2896
2897        let shared_provider = Arc::new(BoxedProvider::from_arc(self.provider.clone()));
2898
2899        // Clone agent pool for FormSquadTool before moving into DelegateTaskTool
2900        let agent_pool = if squads_enabled {
2901            Some(self.sub_agents.clone())
2902        } else {
2903            None
2904        };
2905
2906        let resolved_mode = self
2907            .observability_mode
2908            .unwrap_or(super::observability::ObservabilityMode::Production);
2909
2910        // Build spawn_agent tool pool and validate before moving shared state
2911        let spawn_tool_data = if let Some(spawn_cfg) = self.spawn_config.take() {
2912            let mut tool_pool = std::collections::HashMap::new();
2913            for tool in &self.spawn_builtin_tools {
2914                let name = tool.definition().name;
2915                if spawn_cfg.tool_allowlist.contains(&name) {
2916                    tool_pool.insert(name, tool.clone());
2917                }
2918            }
2919            // Validate all allowlist entries exist
2920            for allowed in &spawn_cfg.tool_allowlist {
2921                if !tool_pool.contains_key(allowed) {
2922                    return Err(Error::Config(format!(
2923                        "orchestrator.spawn.tool_allowlist: unknown tool '{}'. \
2924                         Available builtin tools: [{}]",
2925                        allowed,
2926                        self.spawn_builtin_tools
2927                            .iter()
2928                            .map(|t| t.definition().name)
2929                            .collect::<Vec<_>>()
2930                            .join(", ")
2931                    )));
2932                }
2933            }
2934            // Ensure no delegation tools leak into the pool
2935            tool_pool.remove("delegate_task");
2936            tool_pool.remove("form_squad");
2937            tool_pool.remove("spawn_agent");
2938
2939            let cached_definition = SpawnAgentTool::build_definition(&spawn_cfg);
2940
2941            system.push_str(
2942                "\n\n## Dynamic Agent Spawning\n\
2943                 You also have the **spawn_agent** tool to create specialist agents at runtime \
2944                 when no pre-configured agent fits the task. Use this as a secondary option — \
2945                 prefer delegating to existing agents when they match the need.",
2946            );
2947
2948            Some((spawn_cfg, tool_pool, cached_definition))
2949        } else {
2950            None
2951        };
2952
2953        let delegate_tool: Arc<dyn Tool> = Arc::new(DelegateTaskTool {
2954            shared_provider: shared_provider.clone(),
2955            sub_agents: self.sub_agents,
2956            max_turns: self.max_turns,
2957            max_tokens: self.max_tokens,
2958            permission_rules: self.permission_rules.clone(),
2959            accumulated_tokens: sub_agent_tokens.clone(),
2960            shared_memory: self.shared_memory.clone(),
2961            memory_namespace_prefix: self.memory_namespace_prefix.clone(),
2962            blackboard: self.blackboard.clone(),
2963            knowledge_base: self.knowledge_base.clone(),
2964            cached_definition,
2965            on_event: self.on_event.clone(),
2966            lsp_manager: self.lsp_manager.clone(),
2967            observability_mode: resolved_mode,
2968            allow_shared_write: self.allow_shared_write,
2969            tenant_tracker: self.tenant_tracker.clone(),
2970            guardrails: self.guardrails.clone(),
2971            on_approval: self.on_approval.clone(),
2972            learned_permissions: self.learned_permissions.clone(),
2973            fanout_refused: std::sync::Mutex::new(None),
2974        });
2975
2976        let mut runner_builder = AgentRunner::builder(self.provider)
2977            .name("orchestrator")
2978            .system_prompt(system)
2979            .tool(delegate_tool)
2980            .max_turns(self.max_turns)
2981            .max_tokens(self.max_tokens);
2982
2983        // Unified entry agent (option C): give the orchestrator's OWN runner its
2984        // direct tools (builtins/MCP/run_workflow) + the context-management stack
2985        // so it can answer/do/delegate/run-workflow in one loop. Opt-in.
2986        if self.entry_mode {
2987            if !self.entry_direct_tools.is_empty() {
2988                runner_builder = runner_builder.tools(std::mem::take(&mut self.entry_direct_tools));
2989            }
2990            let cx = std::mem::take(&mut self.entry_context);
2991            if let Some(store) = cx.todo_store {
2992                runner_builder = runner_builder.todo_store(store);
2993            }
2994            if cx.replan_on_verify_fail {
2995                runner_builder = runner_builder.replan_on_verify_fail(true);
2996            }
2997            if let Some(window) = cx.context_window_tokens {
2998                runner_builder = runner_builder.context_window_tokens(window);
2999            }
3000            if let Some(store) = cx.context_recall_store {
3001                // Pair restore-on-demand with a gentle pruner so old tool
3002                // outputs truncate to a restorable marker (otherwise no marker
3003                // is ever produced).
3004                runner_builder = runner_builder
3005                    .context_recall_store(store)
3006                    .session_prune_config(crate::agent::pruner::SessionPruneConfig {
3007                        keep_recent_n: 3,
3008                        pruned_tool_result_max_bytes: 1024,
3009                        ..Default::default()
3010                    });
3011            }
3012            // Judge-gated completion: a SHARED goal slot seeds from the
3013            // build-time entry_goal AND (when a judge is configured) backs the
3014            // runtime `set_goal` tool — the agent installs its own acceptance
3015            // criteria mid-run and the judge gates every later natural stop.
3016            let goal_slot: super::goal::GoalSlot =
3017                Arc::new(std::sync::RwLock::new(self.entry_goal.take()));
3018            if let Some(judge) = self.entry_goal_judge.take() {
3019                runner_builder = runner_builder.tool(Arc::new(
3020                    crate::tool::set_goal::SetGoalTool::new(goal_slot.clone(), judge),
3021                ));
3022            }
3023            runner_builder = runner_builder.goal_slot(goal_slot);
3024            // Request-intent routing (answer/execute/study/clarify contracts).
3025            if let Some(router) = self.entry_request_router.take() {
3026                runner_builder = runner_builder.request_router(router);
3027            }
3028            // Deterministic delegation nudge: prompt-only routing has twice
3029            // failed to make mid-tier models delegate organically (live trace
3030            // evidence 2026-06-07 — zero organic delegations across ~30
3031            // sessions, three models). After N direct calls on one request
3032            // without delegating, the runner injects a one-shot reminder.
3033            let mut nudge_tools = vec!["delegate_task".to_string()];
3034            if squads_enabled {
3035                nudge_tools.push("form_squad".into());
3036            }
3037            if !self.entry_workflow_recipes.is_empty() {
3038                nudge_tools.push("run_workflow".into());
3039            }
3040            runner_builder = runner_builder.delegation_nudge(super::runner::DelegationNudge {
3041                after_tool_calls: ENTRY_DELEGATION_NUDGE_AFTER,
3042                tool_names: nudge_tools,
3043            });
3044        }
3045
3046        // Register form_squad tool when enabled
3047        if let Some(agent_pool) = agent_pool {
3048            // SAFETY: form_squad_definition is always Some when agent_pool is Some
3049            let squad_def = form_squad_definition.expect("squad definition computed when enabled");
3050            let form_squad_tool: Arc<dyn Tool> = Arc::new(FormSquadTool {
3051                shared_provider: shared_provider.clone(),
3052                agent_pool,
3053                default_max_turns: self.max_turns,
3054                default_max_tokens: self.max_tokens,
3055                permission_rules: self.permission_rules.clone(),
3056                accumulated_tokens: sub_agent_tokens.clone(),
3057                shared_memory: self.shared_memory.clone(),
3058                memory_namespace_prefix: self.memory_namespace_prefix.clone(),
3059                blackboard: self.blackboard.clone(),
3060                knowledge_base: self.knowledge_base.clone(),
3061                on_event: self.on_event.clone(),
3062                lsp_manager: self.lsp_manager.clone(),
3063                cached_definition: squad_def,
3064                observability_mode: resolved_mode,
3065                allow_shared_write: self.allow_shared_write,
3066                tenant_tracker: self.tenant_tracker.clone(),
3067                guardrails: self.guardrails.clone(),
3068                on_approval: self.on_approval.clone(),
3069                learned_permissions: self.learned_permissions.clone(),
3070            });
3071            runner_builder = runner_builder.tool(form_squad_tool);
3072        }
3073
3074        // Register spawn_agent tool when configured
3075        if let Some((spawn_cfg, tool_pool, spawn_def)) = spawn_tool_data {
3076            let spawn_tool: Arc<dyn Tool> = Arc::new(SpawnAgentTool {
3077                shared_provider: shared_provider.clone(),
3078                spawn_config: spawn_cfg,
3079                tool_pool,
3080                spawn_count: Arc::new(std::sync::atomic::AtomicU32::new(0)),
3081                spawned_names: Arc::new(Mutex::new(std::collections::HashSet::new())),
3082                accumulated_tokens: sub_agent_tokens.clone(),
3083                permission_rules: self.permission_rules.clone(),
3084                shared_memory: self.shared_memory.clone(),
3085                memory_namespace_prefix: self.memory_namespace_prefix.clone(),
3086                on_event: self.on_event.clone(),
3087                lsp_manager: self.lsp_manager.clone(),
3088                observability_mode: resolved_mode,
3089                workspace: self.workspace.clone(),
3090                guardrails: self.guardrails.clone(),
3091                audit_trail: self.audit_trail.clone(),
3092                audit_user_id: self.audit_user_id.clone(),
3093                audit_tenant_id: self.audit_tenant_id.clone(),
3094                audit_delegation_chain: self.audit_delegation_chain.clone(),
3095                cached_definition: spawn_def,
3096                tenant_tracker: self.tenant_tracker.clone(),
3097                on_approval: self.on_approval.clone(),
3098                learned_permissions: self.learned_permissions.clone(),
3099            });
3100            runner_builder = runner_builder.tool(spawn_tool);
3101        }
3102
3103        // Give the orchestrator itself memory tools so it can recall/store memories
3104        // directly, without needing to delegate to a sub-agent.
3105        if let Some(ref memory) = self.shared_memory {
3106            let orch_ns = self
3107                .memory_namespace_prefix
3108                .as_deref()
3109                .unwrap_or("orchestrator");
3110            let mem_scope = crate::auth::TenantScope::from_audit_fields(
3111                self.audit_tenant_id.as_deref(),
3112                self.audit_user_id.as_deref(),
3113            );
3114            let mem_tools = crate::memory::shared_tools::shared_memory_tools(
3115                memory.clone(),
3116                orch_ns,
3117                mem_scope,
3118                self.allow_shared_write,
3119            );
3120            runner_builder = runner_builder.tools(mem_tools);
3121        }
3122
3123        if let Some(strategy) = self.context_strategy {
3124            runner_builder = runner_builder.context_strategy(strategy);
3125        }
3126        if let Some(threshold) = self.summarize_threshold {
3127            runner_builder = runner_builder.summarize_threshold(threshold);
3128        }
3129        if let Some(timeout) = self.tool_timeout {
3130            runner_builder = runner_builder.tool_timeout(timeout);
3131        }
3132        if let Some(max) = self.max_tool_output_bytes {
3133            runner_builder = runner_builder.max_tool_output_bytes(max);
3134        }
3135        if let Some(on_text) = self.on_text {
3136            runner_builder = runner_builder.on_text(on_text);
3137        }
3138        if let Some(on_reasoning) = self.on_reasoning {
3139            runner_builder = runner_builder.on_reasoning(on_reasoning);
3140        }
3141        if let Some(on_approval) = self.on_approval {
3142            runner_builder = runner_builder.on_approval(on_approval);
3143        }
3144        // Forward interactive multi-turn + interrupt to the inner runner so the
3145        // orchestrator behaves like a single AgentRunner (one `run` = a full
3146        // session) with delegation available.
3147        if let Some(on_input) = self.on_input {
3148            runner_builder = runner_builder.on_input(on_input);
3149        }
3150        if let Some(interrupt) = self.interrupt {
3151            runner_builder = runner_builder.interrupt(interrupt);
3152        }
3153        if let Some(learned) = self.learned_permissions {
3154            runner_builder = runner_builder.learned_permissions(learned);
3155        }
3156        if let Some(lsp) = self.lsp_manager {
3157            runner_builder = runner_builder.lsp_manager(lsp);
3158        }
3159        if let Some(on_event) = self.on_event {
3160            runner_builder = runner_builder.on_event(on_event);
3161        }
3162        if !self.guardrails.is_empty() {
3163            runner_builder = runner_builder.guardrails(self.guardrails);
3164        }
3165        if let Some(on_question) = self.on_question {
3166            runner_builder = runner_builder.on_question(on_question);
3167        }
3168        if let Some(timeout) = self.run_timeout {
3169            runner_builder = runner_builder.run_timeout(timeout);
3170        }
3171        if let Some(effort) = self.reasoning_effort {
3172            runner_builder = runner_builder.reasoning_effort(effort);
3173        }
3174        if self.enable_reflection {
3175            runner_builder = runner_builder.enable_reflection(true);
3176        }
3177        if let Some(threshold) = self.tool_output_compression_threshold {
3178            runner_builder = runner_builder.tool_output_compression_threshold(threshold);
3179        }
3180        if let Some(max) = self.max_tools_per_turn {
3181            runner_builder = runner_builder.max_tools_per_turn(max);
3182        }
3183        if let Some(max) = self.max_identical_tool_calls {
3184            runner_builder = runner_builder.max_identical_tool_calls(max);
3185        }
3186        if let Some(max) = self.max_fuzzy_identical_tool_calls {
3187            runner_builder = runner_builder.max_fuzzy_identical_tool_calls(max);
3188        }
3189        if let Some(cap) = self.max_tool_calls_per_turn {
3190            runner_builder = runner_builder.max_tool_calls_per_turn(cap);
3191        }
3192        if !self.permission_rules.is_empty() {
3193            runner_builder = runner_builder.permission_rules(self.permission_rules);
3194        }
3195        if let Some(text) = self.instruction_text {
3196            runner_builder = runner_builder.instruction_text(text);
3197        }
3198        if let Some(mode) = self.observability_mode {
3199            runner_builder = runner_builder.observability_mode(mode);
3200        }
3201        if let Some(trail) = self.audit_trail {
3202            runner_builder = runner_builder.audit_trail(trail);
3203        }
3204        if let Some(uid) = self.audit_user_id
3205            && let Some(tid) = self.audit_tenant_id
3206        {
3207            runner_builder = runner_builder.audit_user_context(uid, tid);
3208        }
3209        if !self.audit_delegation_chain.is_empty() {
3210            runner_builder =
3211                runner_builder.audit_delegation_chain(self.audit_delegation_chain.clone());
3212        }
3213        if let Some(tracker) = self.tenant_tracker {
3214            runner_builder = runner_builder.tenant_tracker(tracker);
3215        }
3216
3217        let runner = runner_builder.build()?;
3218
3219        Ok(Orchestrator {
3220            runner,
3221            sub_agent_tokens,
3222        })
3223    }
3224}
3225
3226#[cfg(test)]
3227mod tests {
3228    use super::*;
3229    use crate::llm::types::{
3230        CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
3231    };
3232    use crate::tool::ToolOutput;
3233    use std::sync::Mutex;
3234
3235    struct MockProvider {
3236        responses: Mutex<Vec<CompletionResponse>>,
3237        /// Every request received, for asserting what the LLM actually saw
3238        /// (e.g. tool-result error texts).
3239        captured_requests: Mutex<Vec<CompletionRequest>>,
3240    }
3241
3242    impl MockProvider {
3243        fn new(responses: Vec<CompletionResponse>) -> Self {
3244            Self {
3245                responses: Mutex::new(responses),
3246                captured_requests: Mutex::new(Vec::new()),
3247            }
3248        }
3249    }
3250
3251    impl LlmProvider for MockProvider {
3252        async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, Error> {
3253            self.captured_requests
3254                .lock()
3255                .expect("capture lock poisoned")
3256                .push(request);
3257            let mut responses = self.responses.lock().expect("mock lock poisoned");
3258            if responses.is_empty() {
3259                return Err(Error::Agent("no more mock responses".into()));
3260            }
3261            Ok(responses.remove(0))
3262        }
3263
3264        fn model_name(&self) -> Option<&str> {
3265            Some("mock-model-v1")
3266        }
3267    }
3268
3269    /// A mock tool for orchestrator tests. Returns a fixed response.
3270    struct MockTool {
3271        def: crate::llm::types::ToolDefinition,
3272        response: String,
3273    }
3274
3275    impl MockTool {
3276        fn new(name: &str, response: &str) -> Self {
3277            Self {
3278                def: crate::llm::types::ToolDefinition {
3279                    name: name.into(),
3280                    description: format!("Mock {name}"),
3281                    input_schema: json!({"type": "object"}),
3282                },
3283                response: response.into(),
3284            }
3285        }
3286    }
3287
3288    impl crate::tool::Tool for MockTool {
3289        fn definition(&self) -> crate::llm::types::ToolDefinition {
3290            self.def.clone()
3291        }
3292
3293        fn execute(
3294            &self,
3295            _ctx: &crate::ExecutionContext,
3296            _input: serde_json::Value,
3297        ) -> std::pin::Pin<
3298            Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
3299        > {
3300            let response = self.response.clone();
3301            Box::pin(async move { Ok(ToolOutput::success(response)) })
3302        }
3303    }
3304
3305    /// A tool that records whether it was ever executed — used to prove the
3306    /// human-approval gate (deny) actually prevented execution in sub-agents.
3307    struct TrackedTool {
3308        def: crate::llm::types::ToolDefinition,
3309        executed: Arc<std::sync::atomic::AtomicBool>,
3310    }
3311
3312    impl TrackedTool {
3313        #[allow(clippy::type_complexity)]
3314        fn make(
3315            name: &str,
3316        ) -> (
3317            Arc<dyn crate::tool::Tool>,
3318            Arc<std::sync::atomic::AtomicBool>,
3319        ) {
3320            let executed = Arc::new(std::sync::atomic::AtomicBool::new(false));
3321            let tool = Arc::new(Self {
3322                def: crate::llm::types::ToolDefinition {
3323                    name: name.into(),
3324                    description: format!("Tracked {name}"),
3325                    input_schema: json!({"type": "object"}),
3326                },
3327                executed: executed.clone(),
3328            });
3329            (tool, executed)
3330        }
3331    }
3332
3333    impl crate::tool::Tool for TrackedTool {
3334        fn definition(&self) -> crate::llm::types::ToolDefinition {
3335            self.def.clone()
3336        }
3337
3338        fn execute(
3339            &self,
3340            _ctx: &crate::ExecutionContext,
3341            _input: serde_json::Value,
3342        ) -> std::pin::Pin<
3343            Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
3344        > {
3345            self.executed
3346                .store(true, std::sync::atomic::Ordering::SeqCst);
3347            Box::pin(async { Ok(ToolOutput::success("mutated")) })
3348        }
3349    }
3350
3351    /// Approval callback that records every tool name it is asked about,
3352    /// denies `danger_tool`, and allows everything else.
3353    #[allow(clippy::type_complexity)]
3354    fn deny_danger_approval() -> (Arc<crate::llm::OnApproval>, Arc<Mutex<Vec<String>>>) {
3355        let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
3356        let seen_cb = seen.clone();
3357        let cb: Arc<crate::llm::OnApproval> = Arc::new(move |calls| {
3358            let mut guard = seen_cb.lock().expect("seen lock");
3359            for call in calls {
3360                guard.push(call.name.clone());
3361            }
3362            if calls.iter().any(|c| c.name == "danger_tool") {
3363                crate::llm::ApprovalDecision::Deny
3364            } else {
3365                crate::llm::ApprovalDecision::Allow
3366            }
3367        });
3368        (cb, seen)
3369    }
3370
3371    #[test]
3372    fn system_prompt_includes_agents() {
3373        let tools_a = vec!["web_search".to_string(), "read_file".to_string()];
3374        let tools_b: Vec<String> = vec![];
3375        let agents: Vec<(&str, &str, &[String])> = vec![
3376            ("researcher", "Research specialist", tools_a.as_slice()),
3377            ("coder", "Coding expert", tools_b.as_slice()),
3378        ];
3379
3380        let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
3381        assert!(prompt.contains("researcher"));
3382        assert!(prompt.contains("Research specialist"));
3383        assert!(prompt.contains("coder"));
3384        assert!(prompt.contains("Tools: web_search, read_file"));
3385        assert!(prompt.contains("Tools: (none)"));
3386        // New structured sections
3387        assert!(
3388            prompt.contains("Decision Process"),
3389            "prompt should contain Decision Process section: {prompt}"
3390        );
3391        assert!(
3392            prompt.contains("Effort Scaling"),
3393            "prompt should contain Effort Scaling section: {prompt}"
3394        );
3395        assert!(
3396            prompt.contains("Task Quality"),
3397            "prompt should contain Task Quality section: {prompt}"
3398        );
3399        assert!(
3400            prompt.contains("DECOMPOSE"),
3401            "prompt should contain decomposition guidance: {prompt}"
3402        );
3403    }
3404
3405    #[test]
3406    fn system_prompt_shows_tool_names() {
3407        let tools = vec![
3408            "web_search".to_string(),
3409            "read_file".to_string(),
3410            "knowledge_search".to_string(),
3411        ];
3412        let no_tools: Vec<String> = vec![];
3413        let agents: Vec<(&str, &str, &[String])> = vec![
3414            ("researcher", "Research specialist", tools.as_slice()),
3415            ("analyst", "Analytical thinker", no_tools.as_slice()),
3416        ];
3417
3418        let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
3419        assert!(
3420            prompt.contains("Tools: web_search, read_file, knowledge_search"),
3421            "prompt should list tool names: {prompt}"
3422        );
3423        assert!(
3424            prompt.contains("Tools: (none)"),
3425            "prompt should show (none) for agents without tools: {prompt}"
3426        );
3427    }
3428
3429    #[test]
3430    fn system_prompt_sequential_says_one_at_a_time() {
3431        let tools: Vec<String> = vec![];
3432        let agents: Vec<(&str, &str, &[String])> =
3433            vec![("builder", "Builds stuff", tools.as_slice())];
3434        let prompt = build_system_prompt(&agents, false, DispatchMode::Sequential);
3435        assert!(
3436            prompt.contains("ONE agent at a time"),
3437            "sequential prompt should say one at a time: {prompt}"
3438        );
3439        assert!(
3440            !prompt.contains("parallel execution"),
3441            "sequential prompt should not mention parallel: {prompt}"
3442        );
3443    }
3444
3445    #[test]
3446    fn system_prompt_parallel_says_parallel() {
3447        let tools: Vec<String> = vec![];
3448        let agents: Vec<(&str, &str, &[String])> =
3449            vec![("builder", "Builds stuff", tools.as_slice())];
3450        let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
3451        assert!(
3452            prompt.contains("parallel execution"),
3453            "parallel prompt should mention parallel: {prompt}"
3454        );
3455    }
3456
3457    #[test]
3458    fn delegate_schema_sequential_max_items_1() {
3459        let tools: Vec<String> = vec![];
3460        let agents: Vec<(&str, &str, &[String])> =
3461            vec![("builder", "Builds stuff", tools.as_slice())];
3462        let def = build_delegate_tool_schema(&agents, DispatchMode::Sequential);
3463        let tasks = &def.input_schema["properties"]["tasks"];
3464        assert_eq!(
3465            tasks["maxItems"], 1,
3466            "sequential schema should have maxItems=1: {tasks}"
3467        );
3468        assert!(
3469            def.description.contains("ONE sub-agent"),
3470            "sequential description should say ONE: {}",
3471            def.description
3472        );
3473    }
3474
3475    #[test]
3476    fn delegate_schema_parallel_no_max_items() {
3477        let tools: Vec<String> = vec![];
3478        let agents: Vec<(&str, &str, &[String])> =
3479            vec![("builder", "Builds stuff", tools.as_slice())];
3480        let def = build_delegate_tool_schema(&agents, DispatchMode::Parallel);
3481        let tasks = &def.input_schema["properties"]["tasks"];
3482        assert!(
3483            tasks.get("maxItems").is_none(),
3484            "parallel schema should not have maxItems: {tasks}"
3485        );
3486    }
3487
3488    #[tokio::test]
3489    async fn sequential_dispatch_disables_squads() {
3490        // With 2 agents, squads auto-enable. Sequential mode should force them off.
3491        // We verify by running the orchestrator and checking that the LLM request
3492        // only contains delegate_task (not form_squad).
3493        let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
3494            content: vec![ContentBlock::Text {
3495                text: "done".into(),
3496            }],
3497            stop_reason: StopReason::EndTurn,
3498            reasoning: None,
3499            usage: TokenUsage::default(),
3500            model: None,
3501        }]));
3502        let mut orch = Orchestrator::builder(provider)
3503            .sub_agent("a", "Agent A", "prompt a")
3504            .sub_agent("b", "Agent B", "prompt b")
3505            .dispatch_mode(DispatchMode::Sequential)
3506            .build()
3507            .unwrap();
3508        let output = orch.run("test").await.unwrap();
3509        assert_eq!(output.result, "done");
3510        // The system prompt should NOT mention form_squad
3511        // (indirectly verifies squads were disabled)
3512    }
3513
3514    #[tokio::test]
3515    async fn orchestrator_runs_multi_turn_via_on_input() {
3516        // With `on_input` forwarded to the inner runner, a SINGLE `run()` drives a
3517        // whole multi-turn session: turn 1 answers, the runner awaits the next
3518        // message, turn 2 answers, then `on_input` ends the session.
3519        fn text(t: &str) -> CompletionResponse {
3520            CompletionResponse {
3521                content: vec![ContentBlock::Text { text: t.into() }],
3522                stop_reason: StopReason::EndTurn,
3523                reasoning: None,
3524                usage: TokenUsage::default(),
3525                model: None,
3526            }
3527        }
3528        let provider = Arc::new(MockProvider::new(vec![text("first"), text("second")]));
3529        let inputs = Arc::new(std::sync::Mutex::new(vec![Some("again".to_string()), None]));
3530        let on_input: Arc<crate::agent::OnInput> = {
3531            let inputs = inputs.clone();
3532            Arc::new(move || {
3533                let inputs = inputs.clone();
3534                Box::pin(async move {
3535                    let mut g = inputs.lock().expect("lock");
3536                    if g.is_empty() { None } else { g.remove(0) }
3537                })
3538            })
3539        };
3540        let mut orch = Orchestrator::builder(provider)
3541            .sub_agent("a", "Agent A", "prompt a")
3542            .sub_agent("b", "Agent B", "prompt b")
3543            .on_input(on_input)
3544            .build()
3545            .unwrap();
3546        let output = orch.run("start").await.unwrap();
3547        assert_eq!(
3548            output.result, "second",
3549            "a single run() drives the full multi-turn session via on_input"
3550        );
3551    }
3552
3553    #[tokio::test]
3554    async fn orchestrator_interrupt_ends_turn_then_continues() {
3555        // A pre-triggered interrupt makes the inner runner's biased select abandon
3556        // the first turn; the follow-up (via on_input) then produces the answer.
3557        use crate::agent::interrupt::InterruptHandle;
3558        fn text(t: &str) -> CompletionResponse {
3559            CompletionResponse {
3560                content: vec![ContentBlock::Text { text: t.into() }],
3561                stop_reason: StopReason::EndTurn,
3562                reasoning: None,
3563                usage: TokenUsage::default(),
3564                model: None,
3565            }
3566        }
3567        let provider = Arc::new(MockProvider::new(vec![text("real answer")]));
3568        let interrupt = InterruptHandle::new();
3569        interrupt.interrupt();
3570        let inputs = Arc::new(std::sync::Mutex::new(vec![
3571            Some("follow up".to_string()),
3572            None,
3573        ]));
3574        let on_input: Arc<crate::agent::OnInput> = {
3575            let inputs = inputs.clone();
3576            Arc::new(move || {
3577                let inputs = inputs.clone();
3578                Box::pin(async move {
3579                    let mut g = inputs.lock().expect("lock");
3580                    if g.is_empty() { None } else { g.remove(0) }
3581                })
3582            })
3583        };
3584        let mut orch = Orchestrator::builder(provider)
3585            .sub_agent("a", "Agent A", "prompt a")
3586            .sub_agent("b", "Agent B", "prompt b")
3587            .on_input(on_input)
3588            .interrupt(interrupt)
3589            .build()
3590            .unwrap();
3591        let output = orch.run("start").await.unwrap();
3592        assert_eq!(output.result, "real answer");
3593    }
3594
3595    #[test]
3596    fn sequential_dispatch_disables_squads_in_prompt() {
3597        let tools: Vec<String> = vec![];
3598        let agents: Vec<(&str, &str, &[String])> = vec![
3599            ("a", "Agent A", tools.as_slice()),
3600            ("b", "Agent B", tools.as_slice()),
3601        ];
3602        // Sequential + squads_enabled=true should still produce a prompt without form_squad
3603        let prompt = build_system_prompt(&agents, false, DispatchMode::Sequential);
3604        assert!(
3605            !prompt.contains("form_squad"),
3606            "sequential prompt should not mention form_squad: {prompt}"
3607        );
3608    }
3609
3610    #[test]
3611    fn delegate_tool_schema_includes_agents() {
3612        let tools = vec!["web_search".to_string()];
3613        let agents: Vec<(&str, &str, &[String])> =
3614            vec![("researcher", "Research", tools.as_slice())];
3615        let def = build_delegate_tool_schema(&agents, DispatchMode::Parallel);
3616        assert_eq!(def.name, "delegate_task");
3617        assert!(def.description.contains("researcher"));
3618        assert!(
3619            def.description.contains("web_search"),
3620            "delegate tool description should contain tool names: {}",
3621            def.description
3622        );
3623        assert!(
3624            def.description.contains("isolation"),
3625            "delegate tool description should mention isolation: {}",
3626            def.description
3627        );
3628        assert!(
3629            def.description.contains("self-contained"),
3630            "delegate tool description should mention self-contained tasks: {}",
3631            def.description
3632        );
3633    }
3634
3635    #[test]
3636    fn delegate_tool_definition_includes_agents() {
3637        let agents: Vec<(&str, &str, &[String])> = vec![("researcher", "Research", &[])];
3638        let cached_definition = build_delegate_tool_schema(&agents, DispatchMode::Parallel);
3639
3640        let tool = DelegateTaskTool {
3641            shared_provider: Arc::new(BoxedProvider::new(MockProvider::new(vec![]))),
3642            sub_agents: vec![SubAgentDef {
3643                name: "researcher".into(),
3644                description: "Research".into(),
3645                system_prompt: "prompt".into(),
3646                tools: vec![],
3647                context_strategy: None,
3648                summarize_threshold: None,
3649                tool_timeout: None,
3650                max_tool_output_bytes: None,
3651                max_turns: None,
3652                max_tokens: None,
3653                response_schema: None,
3654                run_timeout: None,
3655                guardrails: vec![],
3656                provider_override: None,
3657                reasoning_effort: None,
3658                enable_reflection: None,
3659                tool_output_compression_threshold: None,
3660                max_tools_per_turn: None,
3661                tool_profile: None,
3662                max_identical_tool_calls: None,
3663                max_fuzzy_identical_tool_calls: None,
3664                max_tool_calls_per_turn: None,
3665                session_prune_config: None,
3666                enable_recursive_summarization: None,
3667                reflection_threshold: None,
3668                consolidate_on_exit: None,
3669                workspace: None,
3670                max_total_tokens: None,
3671                audit_trail: None,
3672                audit_user_id: None,
3673                audit_tenant_id: None,
3674                audit_delegation_chain: Vec::new(),
3675                context: Default::default(),
3676            }],
3677            shared_memory: None,
3678            memory_namespace_prefix: None,
3679            blackboard: None,
3680            knowledge_base: None,
3681            max_turns: 10,
3682            max_tokens: 4096,
3683            permission_rules: crate::agent::permission::PermissionRuleset::default(),
3684            accumulated_tokens: Arc::new(Mutex::new(TokenUsage::default())),
3685            cached_definition,
3686            on_event: None,
3687            lsp_manager: None,
3688            observability_mode: crate::ObservabilityMode::Production,
3689            allow_shared_write: true,
3690            tenant_tracker: None,
3691            guardrails: vec![],
3692            on_approval: None,
3693            learned_permissions: None,
3694            fanout_refused: std::sync::Mutex::new(None),
3695        };
3696
3697        let def = tool.definition();
3698        assert_eq!(def.name, "delegate_task");
3699        assert!(def.description.contains("researcher"));
3700        assert!(
3701            def.description.contains("tools"),
3702            "delegate tool description should contain 'tools' key: {}",
3703            def.description
3704        );
3705    }
3706
3707    #[test]
3708    fn build_errors_on_duplicate_sub_agent_names() {
3709        let provider = Arc::new(MockProvider::new(vec![]));
3710        let result = Orchestrator::builder(provider)
3711            .sub_agent("researcher", "Research 1", "prompt1")
3712            .sub_agent("researcher", "Research 2", "prompt2")
3713            .build();
3714        assert!(result.is_err());
3715        let err = result.err().unwrap();
3716        assert!(
3717            err.to_string()
3718                .contains("duplicate sub-agent name: 'researcher'"),
3719            "error: {err}"
3720        );
3721    }
3722
3723    #[tokio::test]
3724    async fn orchestrator_direct_response_no_delegation() {
3725        let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
3726            content: vec![ContentBlock::Text {
3727                text: "Simple answer.".into(),
3728            }],
3729            stop_reason: StopReason::EndTurn,
3730            reasoning: None,
3731            usage: TokenUsage {
3732                input_tokens: 10,
3733                output_tokens: 5,
3734                ..Default::default()
3735            },
3736            model: None,
3737        }]));
3738
3739        let mut orch = Orchestrator::builder(provider)
3740            .sub_agent("researcher", "Research", "prompt")
3741            .build()
3742            .unwrap();
3743
3744        let output = orch.run("simple question").await.unwrap();
3745        assert_eq!(output.result, "Simple answer.");
3746        assert_eq!(output.tool_calls_made, 0);
3747    }
3748
3749    #[tokio::test]
3750    async fn orchestrator_delegates_and_synthesizes() {
3751        // Responses consumed in order by provider. The orchestrator calls provider,
3752        // then sub-agents call provider (in spawn order under single-threaded tokio test runtime),
3753        // then orchestrator calls provider again for synthesis.
3754        let provider = Arc::new(MockProvider::new(vec![
3755            // 1: Orchestrator decides to delegate
3756            CompletionResponse {
3757                content: vec![ContentBlock::ToolUse {
3758                    id: "call-1".into(),
3759                    name: "delegate_task".into(),
3760                    input: json!({
3761                        "tasks": [
3762                            {"agent": "researcher", "task": "Research Rust"},
3763                            {"agent": "analyst", "task": "Analyze findings"}
3764                        ]
3765                    }),
3766                }],
3767                stop_reason: StopReason::ToolUse,
3768                reasoning: None,
3769                usage: TokenUsage {
3770                    input_tokens: 50,
3771                    output_tokens: 20,
3772                    ..Default::default()
3773                },
3774                model: None,
3775            },
3776            // 2: Sub-agent "researcher" response
3777            CompletionResponse {
3778                content: vec![ContentBlock::Text {
3779                    text: "Rust is fast and safe.".into(),
3780                }],
3781                stop_reason: StopReason::EndTurn,
3782                reasoning: None,
3783                usage: TokenUsage {
3784                    input_tokens: 10,
3785                    output_tokens: 8,
3786                    ..Default::default()
3787                },
3788                model: None,
3789            },
3790            // 3: Sub-agent "analyst" response
3791            CompletionResponse {
3792                content: vec![ContentBlock::Text {
3793                    text: "Strengths: memory safety, performance.".into(),
3794                }],
3795                stop_reason: StopReason::EndTurn,
3796                reasoning: None,
3797                usage: TokenUsage {
3798                    input_tokens: 12,
3799                    output_tokens: 10,
3800                    ..Default::default()
3801                },
3802                model: None,
3803            },
3804            // 4: Orchestrator synthesis
3805            CompletionResponse {
3806                content: vec![ContentBlock::Text {
3807                    text: "Based on research: Rust is excellent.".into(),
3808                }],
3809                stop_reason: StopReason::EndTurn,
3810                reasoning: None,
3811                usage: TokenUsage {
3812                    input_tokens: 80,
3813                    output_tokens: 30,
3814                    ..Default::default()
3815                },
3816                model: None,
3817            },
3818        ]));
3819
3820        let mut orch = Orchestrator::builder(provider)
3821            .sub_agent("researcher", "Research specialist", "You research.")
3822            .sub_agent("analyst", "Analysis expert", "You analyze.")
3823            .build()
3824            .unwrap();
3825
3826        let output = orch.run("Analyze Rust").await.unwrap();
3827        assert_eq!(output.result, "Based on research: Rust is excellent.");
3828        assert_eq!(output.tool_calls_made, 1); // one delegate_task call
3829        // Orchestrator tokens (50+80 in, 20+30 out) + sub-agent tokens (10+12 in, 8+10 out)
3830        assert_eq!(output.tokens_used.input_tokens, 50 + 80 + 10 + 12);
3831        assert_eq!(output.tokens_used.output_tokens, 20 + 30 + 8 + 10);
3832    }
3833
3834    #[tokio::test]
3835    async fn orchestrator_handles_unknown_agent_gracefully() {
3836        // Unknown agent now returns error in tool result, not a hard crash
3837        let provider = Arc::new(MockProvider::new(vec![
3838            CompletionResponse {
3839                content: vec![ContentBlock::ToolUse {
3840                    id: "call-1".into(),
3841                    name: "delegate_task".into(),
3842                    input: json!({
3843                        "tasks": [{"agent": "nonexistent", "task": "do stuff"}]
3844                    }),
3845                }],
3846                stop_reason: StopReason::ToolUse,
3847                reasoning: None,
3848                usage: TokenUsage::default(),
3849                model: None,
3850            },
3851            // Orchestrator recovers after seeing the error
3852            CompletionResponse {
3853                content: vec![ContentBlock::Text {
3854                    text: "No such agent available.".into(),
3855                }],
3856                stop_reason: StopReason::EndTurn,
3857                reasoning: None,
3858                usage: TokenUsage::default(),
3859                model: None,
3860            },
3861        ]));
3862
3863        let mut orch = Orchestrator::builder(provider)
3864            .sub_agent("researcher", "Research", "prompt")
3865            .build()
3866            .unwrap();
3867
3868        let output = orch.run("delegate to unknown").await.unwrap();
3869        assert_eq!(output.result, "No such agent available.");
3870    }
3871
3872    #[tokio::test]
3873    async fn orchestrator_handles_invalid_tool_name() {
3874        let provider = Arc::new(MockProvider::new(vec![
3875            CompletionResponse {
3876                content: vec![ContentBlock::ToolUse {
3877                    id: "call-1".into(),
3878                    name: "wrong_tool".into(),
3879                    input: json!({}),
3880                }],
3881                stop_reason: StopReason::ToolUse,
3882                reasoning: None,
3883                usage: TokenUsage::default(),
3884                model: None,
3885            },
3886            CompletionResponse {
3887                content: vec![ContentBlock::Text {
3888                    text: "Sorry, let me respond directly.".into(),
3889                }],
3890                stop_reason: StopReason::EndTurn,
3891                reasoning: None,
3892                usage: TokenUsage::default(),
3893                model: None,
3894            },
3895        ]));
3896
3897        let mut orch = Orchestrator::builder(provider)
3898            .sub_agent("researcher", "Research", "prompt")
3899            .build()
3900            .unwrap();
3901
3902        let output = orch.run("do something").await.unwrap();
3903        assert_eq!(output.result, "Sorry, let me respond directly.");
3904    }
3905
3906    #[tokio::test]
3907    async fn orchestrator_handles_empty_delegate_tasks() {
3908        let provider = Arc::new(MockProvider::new(vec![
3909            // 1: LLM sends delegate_task with empty tasks array
3910            CompletionResponse {
3911                content: vec![ContentBlock::ToolUse {
3912                    id: "call-1".into(),
3913                    name: "delegate_task".into(),
3914                    input: json!({"tasks": []}),
3915                }],
3916                stop_reason: StopReason::ToolUse,
3917                reasoning: None,
3918                usage: TokenUsage::default(),
3919                model: None,
3920            },
3921            // 2: LLM recovers after seeing the error
3922            CompletionResponse {
3923                content: vec![ContentBlock::Text {
3924                    text: "Let me try again properly.".into(),
3925                }],
3926                stop_reason: StopReason::EndTurn,
3927                reasoning: None,
3928                usage: TokenUsage::default(),
3929                model: None,
3930            },
3931        ]));
3932
3933        let mut orch = Orchestrator::builder(provider)
3934            .sub_agent("researcher", "Research", "prompt")
3935            .build()
3936            .unwrap();
3937
3938        let output = orch.run("do something").await.unwrap();
3939        assert_eq!(output.result, "Let me try again properly.");
3940    }
3941
3942    #[tokio::test]
3943    async fn orchestrator_handles_missing_tasks_field() {
3944        let provider = Arc::new(MockProvider::new(vec![
3945            // 1: LLM sends delegate_task without tasks field
3946            CompletionResponse {
3947                content: vec![ContentBlock::ToolUse {
3948                    id: "call-1".into(),
3949                    name: "delegate_task".into(),
3950                    input: json!({}),
3951                }],
3952                stop_reason: StopReason::ToolUse,
3953                reasoning: None,
3954                usage: TokenUsage::default(),
3955                model: None,
3956            },
3957            // 2: LLM recovers after seeing the parse error
3958            CompletionResponse {
3959                content: vec![ContentBlock::Text {
3960                    text: "I need to format correctly.".into(),
3961                }],
3962                stop_reason: StopReason::EndTurn,
3963                reasoning: None,
3964                usage: TokenUsage::default(),
3965                model: None,
3966            },
3967        ]));
3968
3969        let mut orch = Orchestrator::builder(provider)
3970            .sub_agent("researcher", "Research", "prompt")
3971            .build()
3972            .unwrap();
3973
3974        let output = orch.run("do something").await.unwrap();
3975        assert_eq!(output.result, "I need to format correctly.");
3976    }
3977
3978    #[tokio::test]
3979    async fn blackboard_populated_after_delegation() {
3980        use crate::agent::blackboard::InMemoryBlackboard;
3981
3982        let bb = Arc::new(InMemoryBlackboard::new());
3983
3984        let provider = Arc::new(MockProvider::new(vec![
3985            // 1: Orchestrator delegates to researcher
3986            CompletionResponse {
3987                content: vec![ContentBlock::ToolUse {
3988                    id: "call-1".into(),
3989                    name: "delegate_task".into(),
3990                    input: json!({
3991                        "tasks": [{"agent": "researcher", "task": "Find info"}]
3992                    }),
3993                }],
3994                stop_reason: StopReason::ToolUse,
3995                reasoning: None,
3996                usage: TokenUsage::default(),
3997                model: None,
3998            },
3999            // 2: Sub-agent responds
4000            CompletionResponse {
4001                content: vec![ContentBlock::Text {
4002                    text: "Research result here.".into(),
4003                }],
4004                stop_reason: StopReason::EndTurn,
4005                reasoning: None,
4006                usage: TokenUsage::default(),
4007                model: None,
4008            },
4009            // 3: Orchestrator synthesis
4010            CompletionResponse {
4011                content: vec![ContentBlock::Text {
4012                    text: "Done.".into(),
4013                }],
4014                stop_reason: StopReason::EndTurn,
4015                reasoning: None,
4016                usage: TokenUsage::default(),
4017                model: None,
4018            },
4019        ]));
4020
4021        let mut orch = Orchestrator::builder(provider)
4022            .sub_agent("researcher", "Research specialist", "You research.")
4023            .blackboard(bb.clone())
4024            .build()
4025            .unwrap();
4026
4027        orch.run("research something").await.unwrap();
4028
4029        // Verify the blackboard has the agent result
4030        let val: Option<serde_json::Value> = bb.read("agent:researcher").await.unwrap();
4031        assert!(val.is_some(), "blackboard should have agent:researcher key");
4032        assert_eq!(
4033            val.unwrap(),
4034            serde_json::Value::String("Research result here.".into())
4035        );
4036    }
4037
4038    #[tokio::test]
4039    async fn sub_agents_receive_blackboard_tools() {
4040        use crate::agent::blackboard::InMemoryBlackboard;
4041        use crate::llm::types::CompletionRequest;
4042
4043        // Track tool definitions seen by the sub-agent
4044        struct ToolTrackingProvider {
4045            responses: Mutex<Vec<CompletionResponse>>,
4046            tool_names_seen: Mutex<Vec<Vec<String>>>,
4047        }
4048
4049        impl LlmProvider for ToolTrackingProvider {
4050            async fn complete(
4051                &self,
4052                request: CompletionRequest,
4053            ) -> Result<CompletionResponse, Error> {
4054                let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
4055                self.tool_names_seen.lock().expect("lock").push(names);
4056
4057                let mut responses = self.responses.lock().expect("lock");
4058                if responses.is_empty() {
4059                    return Err(Error::Agent("no more mock responses".into()));
4060                }
4061                Ok(responses.remove(0))
4062            }
4063        }
4064
4065        let bb = Arc::new(InMemoryBlackboard::new());
4066
4067        let provider = Arc::new(ToolTrackingProvider {
4068            responses: Mutex::new(vec![
4069                // 1: Orchestrator delegates
4070                CompletionResponse {
4071                    content: vec![ContentBlock::ToolUse {
4072                        id: "call-1".into(),
4073                        name: "delegate_task".into(),
4074                        input: json!({
4075                            "tasks": [{"agent": "worker", "task": "do work"}]
4076                        }),
4077                    }],
4078                    stop_reason: StopReason::ToolUse,
4079                    reasoning: None,
4080                    usage: TokenUsage::default(),
4081                    model: None,
4082                },
4083                // 2: Sub-agent responds
4084                CompletionResponse {
4085                    content: vec![ContentBlock::Text {
4086                        text: "Work done.".into(),
4087                    }],
4088                    stop_reason: StopReason::EndTurn,
4089                    reasoning: None,
4090                    usage: TokenUsage::default(),
4091                    model: None,
4092                },
4093                // 3: Orchestrator synthesis
4094                CompletionResponse {
4095                    content: vec![ContentBlock::Text {
4096                        text: "All done.".into(),
4097                    }],
4098                    stop_reason: StopReason::EndTurn,
4099                    reasoning: None,
4100                    usage: TokenUsage::default(),
4101                    model: None,
4102                },
4103            ]),
4104            tool_names_seen: Mutex::new(vec![]),
4105        });
4106
4107        let mut orch = Orchestrator::builder(provider.clone())
4108            .sub_agent("worker", "Worker agent", "You work.")
4109            .blackboard(bb)
4110            .build()
4111            .unwrap();
4112
4113        orch.run("do work").await.unwrap();
4114
4115        // The second LLM call is from the sub-agent — check its tools
4116        let all_tool_names = provider.tool_names_seen.lock().expect("lock");
4117        assert!(
4118            all_tool_names.len() >= 2,
4119            "expected at least 2 LLM calls, got {}",
4120            all_tool_names.len()
4121        );
4122        let sub_agent_tools = &all_tool_names[1];
4123        assert!(
4124            sub_agent_tools.contains(&"blackboard_read".to_string()),
4125            "sub-agent should have blackboard_read tool, got: {sub_agent_tools:?}"
4126        );
4127        assert!(
4128            sub_agent_tools.contains(&"blackboard_write".to_string()),
4129            "sub-agent should have blackboard_write tool, got: {sub_agent_tools:?}"
4130        );
4131        assert!(
4132            sub_agent_tools.contains(&"blackboard_list".to_string()),
4133            "sub-agent should have blackboard_list tool, got: {sub_agent_tools:?}"
4134        );
4135    }
4136
4137    #[test]
4138    fn blackboard_builder_method_works() {
4139        use crate::agent::blackboard::InMemoryBlackboard;
4140
4141        let bb = Arc::new(InMemoryBlackboard::new());
4142        let provider = Arc::new(MockProvider::new(vec![]));
4143
4144        // Should build successfully with blackboard
4145        let result = Orchestrator::builder(provider)
4146            .sub_agent("agent1", "Agent one", "You are agent 1.")
4147            .blackboard(bb)
4148            .build();
4149
4150        assert!(result.is_ok());
4151    }
4152
4153    #[test]
4154    fn knowledge_builder_method_works() {
4155        use crate::knowledge::in_memory::InMemoryKnowledgeBase;
4156
4157        let kb: Arc<dyn KnowledgeBase> = Arc::new(InMemoryKnowledgeBase::new());
4158        let provider = Arc::new(MockProvider::new(vec![]));
4159
4160        let result = Orchestrator::builder(provider)
4161            .sub_agent("agent1", "Agent one", "You are agent 1.")
4162            .knowledge(kb)
4163            .build();
4164
4165        assert!(result.is_ok());
4166    }
4167
4168    #[tokio::test]
4169    async fn sub_agents_receive_knowledge_tools() {
4170        use crate::knowledge::in_memory::InMemoryKnowledgeBase;
4171        use crate::llm::types::CompletionRequest;
4172
4173        struct ToolTrackingProvider {
4174            responses: Mutex<Vec<CompletionResponse>>,
4175            tool_names_seen: Mutex<Vec<Vec<String>>>,
4176        }
4177
4178        impl LlmProvider for ToolTrackingProvider {
4179            async fn complete(
4180                &self,
4181                request: CompletionRequest,
4182            ) -> Result<CompletionResponse, Error> {
4183                let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
4184                self.tool_names_seen.lock().expect("lock").push(names);
4185
4186                let mut responses = self.responses.lock().expect("lock");
4187                if responses.is_empty() {
4188                    return Err(Error::Agent("no more mock responses".into()));
4189                }
4190                Ok(responses.remove(0))
4191            }
4192        }
4193
4194        let kb: Arc<dyn KnowledgeBase> = Arc::new(InMemoryKnowledgeBase::new());
4195
4196        let provider = Arc::new(ToolTrackingProvider {
4197            responses: Mutex::new(vec![
4198                // 1: Orchestrator delegates
4199                CompletionResponse {
4200                    content: vec![ContentBlock::ToolUse {
4201                        id: "call-1".into(),
4202                        name: "delegate_task".into(),
4203                        input: json!({
4204                            "tasks": [{"agent": "worker", "task": "do work"}]
4205                        }),
4206                    }],
4207                    stop_reason: StopReason::ToolUse,
4208                    reasoning: None,
4209                    usage: TokenUsage::default(),
4210                    model: None,
4211                },
4212                // 2: Sub-agent responds
4213                CompletionResponse {
4214                    content: vec![ContentBlock::Text {
4215                        text: "Work done.".into(),
4216                    }],
4217                    stop_reason: StopReason::EndTurn,
4218                    reasoning: None,
4219                    usage: TokenUsage::default(),
4220                    model: None,
4221                },
4222                // 3: Orchestrator synthesis
4223                CompletionResponse {
4224                    content: vec![ContentBlock::Text {
4225                        text: "All done.".into(),
4226                    }],
4227                    stop_reason: StopReason::EndTurn,
4228                    reasoning: None,
4229                    usage: TokenUsage::default(),
4230                    model: None,
4231                },
4232            ]),
4233            tool_names_seen: Mutex::new(vec![]),
4234        });
4235
4236        let mut orch = Orchestrator::builder(provider.clone())
4237            .sub_agent("worker", "Worker agent", "You work.")
4238            .knowledge(kb)
4239            .build()
4240            .unwrap();
4241
4242        orch.run("do work").await.unwrap();
4243
4244        let all_tool_names = provider.tool_names_seen.lock().expect("lock");
4245        assert!(
4246            all_tool_names.len() >= 2,
4247            "expected at least 2 LLM calls, got {}",
4248            all_tool_names.len()
4249        );
4250        let sub_agent_tools = &all_tool_names[1];
4251        assert!(
4252            sub_agent_tools.contains(&"knowledge_search".to_string()),
4253            "sub-agent should have knowledge_search tool, got: {sub_agent_tools:?}"
4254        );
4255    }
4256
4257    #[tokio::test]
4258    async fn orchestrator_accumulates_cache_tokens_through_delegation() {
4259        let provider = Arc::new(MockProvider::new(vec![
4260            // 1: Orchestrator decides to delegate
4261            CompletionResponse {
4262                content: vec![ContentBlock::ToolUse {
4263                    id: "call-1".into(),
4264                    name: "delegate_task".into(),
4265                    input: json!({
4266                        "tasks": [{"agent": "researcher", "task": "Research Rust"}]
4267                    }),
4268                }],
4269                stop_reason: StopReason::ToolUse,
4270                reasoning: None,
4271                usage: TokenUsage {
4272                    input_tokens: 50,
4273                    output_tokens: 20,
4274                    cache_creation_input_tokens: 100,
4275                    cache_read_input_tokens: 0,
4276                    reasoning_tokens: 0,
4277                },
4278                model: None,
4279            },
4280            // 2: Sub-agent "researcher" response (cache hit on second call)
4281            CompletionResponse {
4282                content: vec![ContentBlock::Text {
4283                    text: "Rust is fast.".into(),
4284                }],
4285                stop_reason: StopReason::EndTurn,
4286                reasoning: None,
4287                usage: TokenUsage {
4288                    input_tokens: 10,
4289                    output_tokens: 8,
4290                    cache_creation_input_tokens: 0,
4291                    cache_read_input_tokens: 30,
4292                    reasoning_tokens: 0,
4293                },
4294                model: None,
4295            },
4296            // 3: Orchestrator synthesis (cache hit)
4297            CompletionResponse {
4298                content: vec![ContentBlock::Text {
4299                    text: "Rust is excellent.".into(),
4300                }],
4301                stop_reason: StopReason::EndTurn,
4302                reasoning: None,
4303                usage: TokenUsage {
4304                    input_tokens: 80,
4305                    output_tokens: 30,
4306                    cache_creation_input_tokens: 0,
4307                    cache_read_input_tokens: 90,
4308                    reasoning_tokens: 0,
4309                },
4310                model: None,
4311            },
4312        ]));
4313
4314        let mut orch = Orchestrator::builder(provider)
4315            .sub_agent("researcher", "Research specialist", "You research.")
4316            .build()
4317            .unwrap();
4318
4319        let output = orch.run("Analyze Rust").await.unwrap();
4320        // Orchestrator: 50+80=130 in, 20+30=50 out, 100+0 cache_create, 0+90 cache_read
4321        // Sub-agent: 10 in, 8 out, 0 cache_create, 30 cache_read
4322        assert_eq!(output.tokens_used.input_tokens, 50 + 80 + 10);
4323        assert_eq!(output.tokens_used.output_tokens, 20 + 30 + 8);
4324        assert_eq!(output.tokens_used.cache_creation_input_tokens, 100);
4325        assert_eq!(output.tokens_used.cache_read_input_tokens, 90 + 30);
4326    }
4327
4328    #[tokio::test]
4329    async fn orchestrator_error_includes_sub_agent_tokens() {
4330        // Scenario: orchestrator delegates, sub-agent succeeds, but orchestrator
4331        // hits max turns before synthesizing. The error's partial_usage should
4332        // include both orchestrator AND sub-agent tokens.
4333        let provider = Arc::new(MockProvider::new(vec![
4334            // 1: Orchestrator delegates
4335            CompletionResponse {
4336                content: vec![ContentBlock::ToolUse {
4337                    id: "call-1".into(),
4338                    name: "delegate_task".into(),
4339                    input: json!({
4340                        "tasks": [{"agent": "researcher", "task": "Research Rust"}]
4341                    }),
4342                }],
4343                stop_reason: StopReason::ToolUse,
4344                reasoning: None,
4345                usage: TokenUsage {
4346                    input_tokens: 50,
4347                    output_tokens: 20,
4348                    ..Default::default()
4349                },
4350                model: None,
4351            },
4352            // 2: Sub-agent responds (tokens we must NOT lose)
4353            CompletionResponse {
4354                content: vec![ContentBlock::Text {
4355                    text: "Rust is fast.".into(),
4356                }],
4357                stop_reason: StopReason::EndTurn,
4358                reasoning: None,
4359                usage: TokenUsage {
4360                    input_tokens: 15,
4361                    output_tokens: 10,
4362                    ..Default::default()
4363                },
4364                model: None,
4365            },
4366            // 3: Orchestrator tries to delegate again (turn 2 = max_turns exceeded)
4367            CompletionResponse {
4368                content: vec![ContentBlock::ToolUse {
4369                    id: "call-2".into(),
4370                    name: "delegate_task".into(),
4371                    input: json!({
4372                        "tasks": [{"agent": "researcher", "task": "More research"}]
4373                    }),
4374                }],
4375                stop_reason: StopReason::ToolUse,
4376                reasoning: None,
4377                usage: TokenUsage {
4378                    input_tokens: 80,
4379                    output_tokens: 25,
4380                    ..Default::default()
4381                },
4382                model: None,
4383            },
4384            // 4: Second sub-agent call
4385            CompletionResponse {
4386                content: vec![ContentBlock::Text {
4387                    text: "More info.".into(),
4388                }],
4389                stop_reason: StopReason::EndTurn,
4390                reasoning: None,
4391                usage: TokenUsage {
4392                    input_tokens: 12,
4393                    output_tokens: 8,
4394                    ..Default::default()
4395                },
4396                model: None,
4397            },
4398        ]));
4399
4400        let mut orch = Orchestrator::builder(provider)
4401            .sub_agent("researcher", "Research", "prompt")
4402            .max_turns(2)
4403            .build()
4404            .unwrap();
4405
4406        let err = orch.run("research deeply").await.unwrap_err();
4407
4408        // Error should be exactly one layer of WithPartialUsage wrapping MaxTurnsExceeded
4409        match &err {
4410            Error::WithPartialUsage { source, .. } => {
4411                assert!(
4412                    matches!(**source, Error::MaxTurnsExceeded(2)),
4413                    "inner error should be MaxTurnsExceeded(2), got: {source}"
4414                );
4415            }
4416            other => panic!("expected WithPartialUsage, got: {other}"),
4417        }
4418
4419        let usage = err.partial_usage();
4420        // Orchestrator: 50+80 in, 20+25 out. Sub-agents: 15+12 in, 10+8 out.
4421        assert_eq!(
4422            usage.input_tokens,
4423            50 + 80 + 15 + 12,
4424            "input tokens: orchestrator(50+80) + sub-agent(15+12)"
4425        );
4426        assert_eq!(
4427            usage.output_tokens,
4428            20 + 25 + 10 + 8,
4429            "output tokens: orchestrator(20+25) + sub-agent(10+8)"
4430        );
4431    }
4432
4433    #[tokio::test]
4434    async fn on_event_emits_sub_agent_events() {
4435        use crate::agent::events::AgentEvent;
4436
4437        let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
4438            Arc::new(std::sync::Mutex::new(vec![]));
4439        let events_clone = events.clone();
4440
4441        let provider = Arc::new(MockProvider::new(vec![
4442            // 1: Orchestrator delegates to one agent
4443            CompletionResponse {
4444                content: vec![ContentBlock::ToolUse {
4445                    id: "call-1".into(),
4446                    name: "delegate_task".into(),
4447                    input: json!({
4448                        "tasks": [{"agent": "researcher", "task": "Research Rust"}]
4449                    }),
4450                }],
4451                stop_reason: StopReason::ToolUse,
4452                reasoning: None,
4453                usage: TokenUsage::default(),
4454                model: None,
4455            },
4456            // 2: Sub-agent responds
4457            CompletionResponse {
4458                content: vec![ContentBlock::Text {
4459                    text: "Rust is fast.".into(),
4460                }],
4461                stop_reason: StopReason::EndTurn,
4462                reasoning: None,
4463                usage: TokenUsage {
4464                    input_tokens: 10,
4465                    output_tokens: 5,
4466                    ..Default::default()
4467                },
4468                model: None,
4469            },
4470            // 3: Orchestrator synthesizes
4471            CompletionResponse {
4472                content: vec![ContentBlock::Text {
4473                    text: "Summary: Rust is fast.".into(),
4474                }],
4475                stop_reason: StopReason::EndTurn,
4476                reasoning: None,
4477                usage: TokenUsage::default(),
4478                model: None,
4479            },
4480        ]));
4481
4482        let mut orch = Orchestrator::builder(provider)
4483            .sub_agent("researcher", "Research", "prompt")
4484            .on_event(Arc::new(move |e| {
4485                events_clone.lock().unwrap().push(e);
4486            }))
4487            .build()
4488            .unwrap();
4489
4490        orch.run("research task").await.unwrap();
4491
4492        let events = events.lock().unwrap();
4493
4494        let dispatched: Vec<_> = events
4495            .iter()
4496            .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
4497            .collect();
4498        assert_eq!(dispatched.len(), 1, "expected 1 SubAgentsDispatched");
4499        match &dispatched[0] {
4500            AgentEvent::SubAgentsDispatched { agent, agents } => {
4501                assert_eq!(agent, "orchestrator");
4502                assert_eq!(agents, &["researcher"]);
4503            }
4504            _ => unreachable!(),
4505        }
4506
4507        let completed: Vec<_> = events
4508            .iter()
4509            .filter(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }))
4510            .collect();
4511        assert_eq!(completed.len(), 1, "expected 1 SubAgentCompleted");
4512        match &completed[0] {
4513            AgentEvent::SubAgentCompleted {
4514                agent,
4515                success,
4516                usage,
4517            } => {
4518                assert_eq!(agent, "researcher");
4519                assert!(success);
4520                assert_eq!(usage.input_tokens, 10);
4521            }
4522            _ => unreachable!(),
4523        }
4524    }
4525
4526    /// Contract (orchestrator.rs `on_text` doc): "Sub-agents do not stream — only
4527    /// the orchestrator's own reasoning and final synthesis are emitted
4528    /// incrementally." Live finding 6a25eb4d: one `delegate_task` with 4 tasks
4529    /// spawned 4 parallel `worker` agents, each handed a clone of the SAME
4530    /// `on_text`; their tokens crisscrossed character-by-character into one
4531    /// un-namespaced buffer ("les messages s'entrecroisent"). The fix restores
4532    /// the contract: a delegated sub-agent's streamed text must NOT reach the
4533    /// orchestrator's `on_text`; the orchestrator's synthesis still does.
4534    #[tokio::test]
4535    async fn sub_agent_text_does_not_reach_on_text() {
4536        // A provider whose stream_complete actually invokes on_text per text
4537        // block (the default trait impl ignores it, so MockProvider can't
4538        // exercise this contract).
4539        struct StreamingMock {
4540            responses: Mutex<Vec<CompletionResponse>>,
4541        }
4542        impl LlmProvider for StreamingMock {
4543            async fn complete(
4544                &self,
4545                _request: CompletionRequest,
4546            ) -> Result<CompletionResponse, Error> {
4547                let mut r = self.responses.lock().expect("mock lock");
4548                if r.is_empty() {
4549                    return Err(Error::Agent("no more mock responses".into()));
4550                }
4551                Ok(r.remove(0))
4552            }
4553            async fn stream_complete(
4554                &self,
4555                request: CompletionRequest,
4556                on_text: &crate::llm::OnText,
4557            ) -> Result<CompletionResponse, Error> {
4558                let resp = self.complete(request).await?;
4559                for block in &resp.content {
4560                    if let ContentBlock::Text { text } = block {
4561                        on_text(text);
4562                    }
4563                }
4564                Ok(resp)
4565            }
4566            fn model_name(&self) -> Option<&str> {
4567                Some("stream-mock")
4568            }
4569        }
4570
4571        let texts: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(vec![]));
4572        let texts_clone = texts.clone();
4573
4574        let provider = Arc::new(StreamingMock {
4575            responses: Mutex::new(vec![
4576                // 1: orchestrator delegates (tool call, no streamed text)
4577                CompletionResponse {
4578                    content: vec![ContentBlock::ToolUse {
4579                        id: "call-1".into(),
4580                        name: "delegate_task".into(),
4581                        input: json!({
4582                            "tasks": [{"agent": "researcher", "task": "Research"}]
4583                        }),
4584                    }],
4585                    stop_reason: StopReason::ToolUse,
4586                    reasoning: None,
4587                    usage: TokenUsage::default(),
4588                    model: None,
4589                },
4590                // 2: SUB-AGENT streams text — must NOT reach on_text
4591                CompletionResponse {
4592                    content: vec![ContentBlock::Text {
4593                        text: "SUBAGENT_SECRET".into(),
4594                    }],
4595                    stop_reason: StopReason::EndTurn,
4596                    reasoning: None,
4597                    usage: TokenUsage::default(),
4598                    model: None,
4599                },
4600                // 3: ORCHESTRATOR synthesis — MUST reach on_text
4601                CompletionResponse {
4602                    content: vec![ContentBlock::Text {
4603                        text: "ORCH_SYNTHESIS".into(),
4604                    }],
4605                    stop_reason: StopReason::EndTurn,
4606                    reasoning: None,
4607                    usage: TokenUsage::default(),
4608                    model: None,
4609                },
4610            ]),
4611        });
4612
4613        let mut orch = Orchestrator::builder(provider)
4614            .sub_agent("researcher", "Research", "prompt")
4615            .on_text(Arc::new(move |s: &str| {
4616                texts_clone.lock().unwrap().push(s.to_string());
4617            }))
4618            .build()
4619            .unwrap();
4620
4621        orch.run("task").await.unwrap();
4622
4623        let got = texts.lock().unwrap().join("");
4624        assert!(
4625            got.contains("ORCH_SYNTHESIS"),
4626            "the orchestrator's own synthesis must still stream (got: {got:?})"
4627        );
4628        assert!(
4629            !got.contains("SUBAGENT_SECRET"),
4630            "a delegated sub-agent's text must NOT reach on_text — it crisscrosses \
4631             with other parallel sub-agents (got: {got:?})"
4632        );
4633    }
4634
4635    fn dtask(agent: &str, task: &str) -> DelegatedTask {
4636        DelegatedTask {
4637            agent: agent.into(),
4638            task: task.into(),
4639        }
4640    }
4641
4642    #[test]
4643    fn same_agent_fanout_fires_on_pile_up_only() {
4644        // The live failure (6a25eb4d): 4 parallel tasks ALL to "worker" building one
4645        // interdependent artifact. The ONLY robust, language-agnostic signal on this
4646        // topology (sub-agents share identical tools; mid-tier task text is French /
4647        // slash-free) is the structural same-agent count off the typed `agent` field.
4648        let four_workers = vec![
4649            dtask("worker", "create the dir structure in /tmp/crm_project"),
4650            dtask("worker", "create Cargo.toml"),
4651            dtask("worker", "create src/models.rs"),
4652            dtask("worker", "create src/config.rs"),
4653        ];
4654        assert_eq!(
4655            same_agent_fanout(&four_workers, SAME_AGENT_FANOUT_NUDGE_AT),
4656            Some(("worker".to_string(), 4))
4657        );
4658        // Exactly at threshold (3) fires.
4659        assert_eq!(
4660            same_agent_fanout(&four_workers[..3], SAME_AGENT_FANOUT_NUDGE_AT),
4661            Some(("worker".to_string(), 3))
4662        );
4663        // Below threshold (2 same-agent) does NOT fire — small parallel splits are fine.
4664        assert_eq!(
4665            same_agent_fanout(&four_workers[..2], SAME_AGENT_FANOUT_NUDGE_AT),
4666            None
4667        );
4668        // A normal multi-distinct-agent squad shape does NOT fire.
4669        assert_eq!(
4670            same_agent_fanout(
4671                &[
4672                    dtask("worker", "build X"),
4673                    dtask("researcher", "investigate Y")
4674                ],
4675                SAME_AGENT_FANOUT_NUDGE_AT
4676            ),
4677            None
4678        );
4679        // Mixed batch where ONE name still piles up fires on that name.
4680        assert_eq!(
4681            same_agent_fanout(
4682                &[
4683                    dtask("worker", "a"),
4684                    dtask("worker", "b"),
4685                    dtask("worker", "c"),
4686                    dtask("researcher", "d"),
4687                ],
4688                SAME_AGENT_FANOUT_NUDGE_AT
4689            ),
4690            Some(("worker".to_string(), 3))
4691        );
4692        // 2+2 split across two agents: no single name reaches the threshold → no fire
4693        // (a known gap, accepted — the live anti-pattern is same-agent pile-up).
4694        assert_eq!(
4695            same_agent_fanout(
4696                &[
4697                    dtask("worker", "a"),
4698                    dtask("worker", "b"),
4699                    dtask("researcher", "c"),
4700                    dtask("researcher", "d"),
4701                ],
4702                SAME_AGENT_FANOUT_NUDGE_AT
4703            ),
4704            None
4705        );
4706    }
4707
4708    #[test]
4709    fn batch_fingerprint_is_stable_and_discriminating() {
4710        let a = vec![dtask("worker", "alpha"), dtask("worker", "beta")];
4711        let a2 = vec![dtask("worker", "alpha"), dtask("worker", "beta")];
4712        let b = vec![dtask("worker", "alpha"), dtask("worker", "GAMMA")];
4713        // Identical batch → identical fingerprint (so an explicit retry is recognized).
4714        assert_eq!(batch_fingerprint(&a), batch_fingerprint(&a2));
4715        // Any change in agent/task → different fingerprint (a different bad batch
4716        // re-nudges, it is not silently latched through).
4717        assert_ne!(batch_fingerprint(&a), batch_fingerprint(&b));
4718    }
4719
4720    /// The orchestration nudge end-to-end: a same-agent pile-up is REFUSED ONCE
4721    /// (not dispatched, GateFired emitted), and the EXPLICIT identical retry
4722    /// dispatches — with a distinct `_dispatched` event so retry-through is
4723    /// measurable (advisor: prevent-vs-nudge must be empirical, not assumed).
4724    #[tokio::test]
4725    async fn same_agent_fanout_refused_once_then_dispatches_on_retry() {
4726        use crate::agent::events::AgentEvent;
4727
4728        let events: Arc<std::sync::Mutex<Vec<AgentEvent>>> =
4729            Arc::new(std::sync::Mutex::new(vec![]));
4730        let ev = events.clone();
4731
4732        let fanout = json!({"tasks": [
4733            {"agent": "worker", "task": "create /tmp/p/a.rs"},
4734            {"agent": "worker", "task": "create /tmp/p/b.rs"},
4735            {"agent": "worker", "task": "create /tmp/p/c.rs"}
4736        ]});
4737        let delegate = |input: serde_json::Value| CompletionResponse {
4738            content: vec![ContentBlock::ToolUse {
4739                id: "call-1".into(),
4740                name: "delegate_task".into(),
4741                input,
4742            }],
4743            stop_reason: StopReason::ToolUse,
4744            reasoning: None,
4745            usage: TokenUsage::default(),
4746            model: None,
4747        };
4748        let text = |t: &str| CompletionResponse {
4749            content: vec![ContentBlock::Text { text: t.into() }],
4750            stop_reason: StopReason::EndTurn,
4751            reasoning: None,
4752            usage: TokenUsage::default(),
4753            model: None,
4754        };
4755
4756        let provider = Arc::new(MockProvider::new(vec![
4757            delegate(fanout.clone()), // turn 1: pile-up → REFUSED (no dispatch)
4758            delegate(fanout.clone()), // turn 2: identical retry → DISPATCHES
4759            text("done a"),           // 3 workers on the retry…
4760            text("done b"),
4761            text("done c"),
4762            text("synthesis"), // orchestrator final
4763        ]));
4764
4765        let mut orch = Orchestrator::builder(provider)
4766            .sub_agent("worker", "General implementation agent", "prompt")
4767            .on_event(Arc::new(move |e| ev.lock().unwrap().push(e)))
4768            .build()
4769            .unwrap();
4770
4771        orch.run("build a small project").await.unwrap();
4772
4773        let events = events.lock().unwrap();
4774        let gate_ids: Vec<&str> = events
4775            .iter()
4776            .filter_map(|e| match e {
4777                AgentEvent::GateFired { gate, .. } => Some(gate.as_str()),
4778                _ => None,
4779            })
4780            .collect();
4781        assert!(
4782            gate_ids.contains(&"same_agent_fanout"),
4783            "the pile-up must be refused once with a GateFired nudge: {gate_ids:?}"
4784        );
4785        assert!(
4786            gate_ids.contains(&"same_agent_fanout_dispatched"),
4787            "the explicit retry must dispatch AND record retry-through: {gate_ids:?}"
4788        );
4789        // The sub-agents ran exactly once — on the retry, not the refused attempt.
4790        let dispatched = events
4791            .iter()
4792            .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
4793            .count();
4794        assert_eq!(
4795            dispatched, 1,
4796            "first attempt refused (0 dispatch), retry dispatched (1) → exactly 1"
4797        );
4798    }
4799
4800    #[tokio::test]
4801    async fn sub_agent_receives_guardrails() {
4802        use crate::agent::guardrail::Guardrail;
4803        use crate::llm::types::CompletionRequest;
4804
4805        // A guardrail that injects a marker into system prompts
4806        struct MarkerGuardrail;
4807        impl Guardrail for MarkerGuardrail {
4808            fn pre_llm(
4809                &self,
4810                request: &mut CompletionRequest,
4811            ) -> std::pin::Pin<
4812                Box<dyn std::future::Future<Output = Result<(), crate::error::Error>> + Send + '_>,
4813            > {
4814                request.system = format!("{} [GUARDRAIL_ACTIVE]", request.system);
4815                Box::pin(async { Ok(()) })
4816            }
4817        }
4818
4819        struct CapturingProvider {
4820            responses: Mutex<Vec<CompletionResponse>>,
4821            systems_seen: Mutex<Vec<String>>,
4822        }
4823
4824        impl LlmProvider for CapturingProvider {
4825            async fn complete(
4826                &self,
4827                request: CompletionRequest,
4828            ) -> Result<CompletionResponse, crate::error::Error> {
4829                self.systems_seen
4830                    .lock()
4831                    .unwrap()
4832                    .push(request.system.clone());
4833                let mut responses = self.responses.lock().unwrap();
4834                if responses.is_empty() {
4835                    return Err(crate::error::Error::Agent("no more responses".into()));
4836                }
4837                Ok(responses.remove(0))
4838            }
4839        }
4840
4841        let guardrail: Arc<dyn Guardrail> = Arc::new(MarkerGuardrail);
4842
4843        let provider = Arc::new(CapturingProvider {
4844            responses: Mutex::new(vec![
4845                // 1: Orchestrator delegates
4846                CompletionResponse {
4847                    content: vec![ContentBlock::ToolUse {
4848                        id: "call-1".into(),
4849                        name: "delegate_task".into(),
4850                        input: json!({
4851                            "tasks": [{"agent": "worker", "task": "do work"}]
4852                        }),
4853                    }],
4854                    stop_reason: StopReason::ToolUse,
4855                    reasoning: None,
4856                    usage: TokenUsage::default(),
4857                    model: None,
4858                },
4859                // 2: Sub-agent responds
4860                CompletionResponse {
4861                    content: vec![ContentBlock::Text {
4862                        text: "Work done.".into(),
4863                    }],
4864                    stop_reason: StopReason::EndTurn,
4865                    reasoning: None,
4866                    usage: TokenUsage::default(),
4867                    model: None,
4868                },
4869                // 3: Orchestrator synthesis
4870                CompletionResponse {
4871                    content: vec![ContentBlock::Text {
4872                        text: "All done.".into(),
4873                    }],
4874                    stop_reason: StopReason::EndTurn,
4875                    reasoning: None,
4876                    usage: TokenUsage::default(),
4877                    model: None,
4878                },
4879            ]),
4880            systems_seen: Mutex::new(vec![]),
4881        });
4882
4883        let mut orch = Orchestrator::builder(provider.clone())
4884            .sub_agent_full(SubAgentConfig {
4885                name: "worker".into(),
4886                description: "Worker agent".into(),
4887                system_prompt: "You work.".into(),
4888                tools: vec![],
4889                context_strategy: None,
4890                summarize_threshold: None,
4891                tool_timeout: None,
4892                max_tool_output_bytes: None,
4893                max_turns: None,
4894                max_tokens: None,
4895                response_schema: None,
4896                run_timeout: None,
4897                guardrails: vec![guardrail],
4898                provider: None,
4899                reasoning_effort: None,
4900                enable_reflection: None,
4901                tool_output_compression_threshold: None,
4902                max_tools_per_turn: None,
4903                tool_profile: None,
4904                max_identical_tool_calls: None,
4905                max_fuzzy_identical_tool_calls: None,
4906                max_tool_calls_per_turn: None,
4907                session_prune_config: None,
4908                enable_recursive_summarization: None,
4909                reflection_threshold: None,
4910                consolidate_on_exit: None,
4911                workspace: None,
4912                max_total_tokens: None,
4913                audit_trail: None,
4914                audit_user_id: None,
4915                audit_tenant_id: None,
4916                audit_delegation_chain: Vec::new(),
4917                context: Default::default(),
4918            })
4919            .build()
4920            .unwrap();
4921
4922        orch.run("do work").await.unwrap();
4923
4924        // The sub-agent's system prompt (second LLM call) should contain the guardrail marker
4925        let systems = provider.systems_seen.lock().unwrap();
4926        assert!(
4927            systems.len() >= 2,
4928            "expected at least 2 LLM calls, got {}",
4929            systems.len()
4930        );
4931        // systems[1] is the sub-agent call
4932        assert!(
4933            systems[1].contains("[GUARDRAIL_ACTIVE]"),
4934            "sub-agent system prompt should contain guardrail marker: {}",
4935            systems[1]
4936        );
4937        // systems[0] is the orchestrator call (no guardrail on orchestrator)
4938        assert!(
4939            !systems[0].contains("[GUARDRAIL_ACTIVE]"),
4940            "orchestrator system prompt should NOT contain guardrail marker: {}",
4941            systems[0]
4942        );
4943    }
4944
4945    /// SECURITY (F-AGENT-2): orchestrator-level guardrails must propagate to
4946    /// sub-agents launched via the delegate_task path (not just via SpawnAgentTool).
4947    /// Before the fix, an opérator who hardened the orchestrator with PII /
4948    /// secret-scanner / LLM-judge guardrails saw their defenses silently drop the
4949    /// moment work was delegated to a sub-agent that had no guardrails of its own.
4950    #[tokio::test]
4951    async fn orchestrator_guardrails_propagate_to_delegated_sub_agents() {
4952        use crate::agent::guardrail::Guardrail;
4953        use crate::llm::types::CompletionRequest;
4954
4955        struct MarkerGuardrail;
4956        impl Guardrail for MarkerGuardrail {
4957            fn pre_llm(
4958                &self,
4959                request: &mut CompletionRequest,
4960            ) -> std::pin::Pin<
4961                Box<dyn std::future::Future<Output = Result<(), crate::error::Error>> + Send + '_>,
4962            > {
4963                request.system = format!("{} [ORCH_GUARD_ACTIVE]", request.system);
4964                Box::pin(async { Ok(()) })
4965            }
4966        }
4967
4968        struct CapturingProvider {
4969            responses: Mutex<Vec<CompletionResponse>>,
4970            systems_seen: Mutex<Vec<String>>,
4971        }
4972
4973        impl LlmProvider for CapturingProvider {
4974            async fn complete(
4975                &self,
4976                request: CompletionRequest,
4977            ) -> Result<CompletionResponse, crate::error::Error> {
4978                self.systems_seen
4979                    .lock()
4980                    .unwrap()
4981                    .push(request.system.clone());
4982                let mut responses = self.responses.lock().unwrap();
4983                if responses.is_empty() {
4984                    return Err(crate::error::Error::Agent("no more responses".into()));
4985                }
4986                Ok(responses.remove(0))
4987            }
4988        }
4989
4990        let guardrail: Arc<dyn Guardrail> = Arc::new(MarkerGuardrail);
4991
4992        let provider = Arc::new(CapturingProvider {
4993            responses: Mutex::new(vec![
4994                // 1: Orchestrator delegates
4995                CompletionResponse {
4996                    content: vec![ContentBlock::ToolUse {
4997                        id: "call-1".into(),
4998                        name: "delegate_task".into(),
4999                        input: json!({
5000                            "tasks": [{"agent": "worker", "task": "do work"}]
5001                        }),
5002                    }],
5003                    stop_reason: StopReason::ToolUse,
5004                    reasoning: None,
5005                    usage: TokenUsage::default(),
5006                    model: None,
5007                },
5008                // 2: Sub-agent responds (NO guardrail of its own)
5009                CompletionResponse {
5010                    content: vec![ContentBlock::Text {
5011                        text: "Work done.".into(),
5012                    }],
5013                    stop_reason: StopReason::EndTurn,
5014                    reasoning: None,
5015                    usage: TokenUsage::default(),
5016                    model: None,
5017                },
5018                // 3: Orchestrator synthesis
5019                CompletionResponse {
5020                    content: vec![ContentBlock::Text {
5021                        text: "Synthesized.".into(),
5022                    }],
5023                    stop_reason: StopReason::EndTurn,
5024                    reasoning: None,
5025                    usage: TokenUsage::default(),
5026                    model: None,
5027                },
5028            ]),
5029            systems_seen: Mutex::new(vec![]),
5030        });
5031
5032        // Sub-agent has NO guardrails of its own — the only protection comes
5033        // from the orchestrator-level guardrail being propagated.
5034        let mut orch = Orchestrator::builder(provider.clone())
5035            .guardrail(guardrail)
5036            .sub_agent_full(SubAgentConfig {
5037                name: "worker".into(),
5038                description: "Worker agent".into(),
5039                system_prompt: "You work.".into(),
5040                tools: vec![],
5041                context_strategy: None,
5042                summarize_threshold: None,
5043                tool_timeout: None,
5044                max_tool_output_bytes: None,
5045                max_turns: None,
5046                max_tokens: None,
5047                response_schema: None,
5048                run_timeout: None,
5049                guardrails: vec![],
5050                provider: None,
5051                reasoning_effort: None,
5052                enable_reflection: None,
5053                tool_output_compression_threshold: None,
5054                max_tools_per_turn: None,
5055                tool_profile: None,
5056                max_identical_tool_calls: None,
5057                max_fuzzy_identical_tool_calls: None,
5058                max_tool_calls_per_turn: None,
5059                session_prune_config: None,
5060                enable_recursive_summarization: None,
5061                reflection_threshold: None,
5062                consolidate_on_exit: None,
5063                workspace: None,
5064                max_total_tokens: None,
5065                audit_trail: None,
5066                audit_user_id: None,
5067                audit_tenant_id: None,
5068                audit_delegation_chain: Vec::new(),
5069                context: Default::default(),
5070            })
5071            .build()
5072            .unwrap();
5073
5074        orch.run("do work").await.unwrap();
5075
5076        let systems = provider.systems_seen.lock().unwrap();
5077        assert!(systems.len() >= 2, "expected at least 2 LLM calls");
5078        // systems[1] is the sub-agent call. The marker MUST be present —
5079        // proves the orchestrator's guardrail propagated to the delegated agent.
5080        assert!(
5081            systems[1].contains("[ORCH_GUARD_ACTIVE]"),
5082            "sub-agent system prompt should contain orchestrator guardrail marker; got: {}",
5083            systems[1]
5084        );
5085    }
5086
5087    #[test]
5088    fn entry_agent_prompt_allows_direct_answers() {
5089        let tools = ["bash".to_string()];
5090        let agents: Vec<(&str, &str, &[String])> = vec![("worker", "does work", &tools)];
5091        let recipes = [("deep-review", "review the diff")];
5092        let p = build_entry_agent_prompt(&agents, true, DispatchMode::Parallel, &recipes);
5093        // The whole point: the entry agent is NOT forbidden from answering directly.
5094        assert!(
5095            !p.contains("Never respond to the user directly"),
5096            "entry agent must be allowed to answer directly"
5097        );
5098        let low = p.to_lowercase();
5099        assert!(
5100            low.contains("answer directly"),
5101            "prompt should instruct direct answers for simple turns"
5102        );
5103        // ...but substantive work should DEFAULT to delegation (the fix for
5104        // "never delegates"): concrete triggers + delegation framed as default.
5105        assert!(
5106            low.contains("default for real work") || low.contains("substantive or multi-part"),
5107            "prompt should make delegation the default for substantive work: {p}"
5108        );
5109        assert!(p.contains("worker"), "lists sub-agents");
5110        assert!(p.contains("deep-review"), "lists workflow recipes");
5111        assert!(p.contains("run_workflow"), "mentions the workflow tool");
5112    }
5113
5114    #[test]
5115    fn entry_agent_prompt_without_recipes_omits_workflow_block() {
5116        let agents: Vec<(&str, &str, &[String])> = vec![];
5117        let p = build_entry_agent_prompt(&agents, false, DispatchMode::Parallel, &[]);
5118        assert!(
5119            !p.contains("run_workflow"),
5120            "no recipes → no workflow block"
5121        );
5122        assert!(
5123            p.contains("none configured"),
5124            "empty squad → 'handle everything yourself'"
5125        );
5126    }
5127
5128    #[test]
5129    fn entry_prompt_has_clarify_rule_when_question_tool_available() {
5130        // P6 (completion-loop harness): when the `question` tool is registered,
5131        // the entry prompt carries the when-to-ask policy; without the tool the
5132        // rule is absent (never instruct calling an unregistered tool).
5133        let agents: Vec<(&str, &str, &[String])> = vec![];
5134        let with = build_entry_agent_prompt_ext(
5135            &agents,
5136            false,
5137            DispatchMode::Parallel,
5138            &[],
5139            EntryCaps {
5140                ask_user: true,
5141                ..Default::default()
5142            },
5143        );
5144        let low = with.to_lowercase();
5145        assert!(
5146            low.contains("guessing would cause rework"),
5147            "clarify rule missing: {with}"
5148        );
5149        assert!(
5150            low.contains("question"),
5151            "rule must name the question tool: {with}"
5152        );
5153        assert!(
5154            low.contains("state") && low.contains("assumption"),
5155            "rule must give the proceed-with-assumptions alternative: {with}"
5156        );
5157        let without = build_entry_agent_prompt_ext(
5158            &agents,
5159            false,
5160            DispatchMode::Parallel,
5161            &[],
5162            EntryCaps::default(),
5163        );
5164        assert!(
5165            !without
5166                .to_lowercase()
5167                .contains("guessing would cause rework"),
5168            "no question tool → no clarify rule: {without}"
5169        );
5170    }
5171
5172    // Option C: an entry-mode orchestrator's OWN runner holds direct tools and
5173    // can execute them itself (not just delegate).
5174    #[tokio::test]
5175    async fn entry_agent_runs_its_own_direct_tools() {
5176        use std::sync::atomic::{AtomicUsize, Ordering};
5177
5178        struct CountTool(Arc<AtomicUsize>);
5179        impl Tool for CountTool {
5180            fn definition(&self) -> ToolDefinition {
5181                ToolDefinition {
5182                    name: "noop".into(),
5183                    description: "no-op".into(),
5184                    input_schema: json!({"type": "object", "properties": {}}),
5185                }
5186            }
5187            fn execute(
5188                &self,
5189                _ctx: &crate::ExecutionContext,
5190                _input: serde_json::Value,
5191            ) -> std::pin::Pin<
5192                Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5193            > {
5194                self.0.fetch_add(1, Ordering::SeqCst);
5195                Box::pin(async { Ok(ToolOutput::success("ok")) })
5196            }
5197        }
5198
5199        let calls = Arc::new(AtomicUsize::new(0));
5200        let provider = Arc::new(TailCapturingProvider {
5201            responses: Mutex::new(vec![
5202                // entry agent uses its OWN tool (not delegate_task)
5203                CompletionResponse {
5204                    content: vec![ContentBlock::ToolUse {
5205                        id: "t1".into(),
5206                        name: "noop".into(),
5207                        input: json!({}),
5208                    }],
5209                    stop_reason: StopReason::ToolUse,
5210                    reasoning: None,
5211                    usage: TokenUsage::default(),
5212                    model: None,
5213                },
5214                text_end("done"),
5215            ]),
5216            tails: Mutex::new(vec![]),
5217        });
5218        let mut orch = Orchestrator::builder(provider)
5219            .entry_agent(vec![Arc::new(CountTool(calls.clone()))])
5220            .sub_agent("worker", "does work", "you work")
5221            .build()
5222            .unwrap();
5223        let out = orch.run("do a thing").await.unwrap();
5224        assert_eq!(out.result, "done");
5225        assert_eq!(
5226            calls.load(Ordering::SeqCst),
5227            1,
5228            "entry agent must execute its own direct tool on its own runner"
5229        );
5230    }
5231
5232    // Option C, the SHIPPED config: an entry-mode orchestrator must keep BOTH
5233    // its direct tools AND delegate_task on the same runner (i.e. `entry_agent`'s
5234    // `.tools()` appends, it does not replace the delegation tool). Mock returns
5235    // a noop tool_use, then a delegate_task, then finishes.
5236    #[tokio::test]
5237    async fn entry_agent_can_both_act_and_delegate() {
5238        use std::sync::atomic::{AtomicUsize, Ordering};
5239
5240        struct CountTool(Arc<AtomicUsize>);
5241        impl Tool for CountTool {
5242            fn definition(&self) -> ToolDefinition {
5243                ToolDefinition {
5244                    name: "noop".into(),
5245                    description: "no-op".into(),
5246                    input_schema: json!({"type": "object", "properties": {}}),
5247                }
5248            }
5249            fn execute(
5250                &self,
5251                _ctx: &crate::ExecutionContext,
5252                _input: serde_json::Value,
5253            ) -> std::pin::Pin<
5254                Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5255            > {
5256                self.0.fetch_add(1, Ordering::SeqCst);
5257                Box::pin(async { Ok(ToolOutput::success("ok")) })
5258            }
5259        }
5260
5261        let direct_calls = Arc::new(AtomicUsize::new(0));
5262        let provider = Arc::new(TailCapturingProvider {
5263            responses: Mutex::new(vec![
5264                // 1: entry agent uses its OWN direct tool
5265                CompletionResponse {
5266                    content: vec![ContentBlock::ToolUse {
5267                        id: "t1".into(),
5268                        name: "noop".into(),
5269                        input: json!({}),
5270                    }],
5271                    stop_reason: StopReason::ToolUse,
5272                    reasoning: None,
5273                    usage: TokenUsage::default(),
5274                    model: None,
5275                },
5276                // 2: entry agent ALSO delegates (proves delegate_task survived
5277                // alongside the appended direct tools)
5278                CompletionResponse {
5279                    content: vec![ContentBlock::ToolUse {
5280                        id: "d1".into(),
5281                        name: "delegate_task".into(),
5282                        input: json!({"tasks": [{"agent": "worker", "task": "do it"}]}),
5283                    }],
5284                    stop_reason: StopReason::ToolUse,
5285                    reasoning: None,
5286                    usage: TokenUsage::default(),
5287                    model: None,
5288                },
5289                text_end("worker done"), // sub-agent finishes
5290                text_end("synth"),       // entry agent synthesizes
5291            ]),
5292            tails: Mutex::new(vec![]),
5293        });
5294        let mut orch = Orchestrator::builder(provider.clone())
5295            .entry_agent(vec![Arc::new(CountTool(direct_calls.clone()))])
5296            .sub_agent("worker", "does work", "you work")
5297            .build()
5298            .unwrap();
5299        let out = orch.run("act then delegate").await.unwrap();
5300        assert_eq!(out.result, "synth");
5301        assert_eq!(
5302            direct_calls.load(Ordering::SeqCst),
5303            1,
5304            "entry agent ran its own direct tool"
5305        );
5306        // The sub-agent ran too → there were ≥3 LLM calls (entry act, entry
5307        // delegate, sub-agent, entry synth). If delegate_task had been wiped, the
5308        // delegate tool_use would have errored instead of spawning the worker.
5309        assert!(
5310            provider.tails.lock().unwrap().len() >= 4,
5311            "delegate_task must still be present and spawn the sub-agent"
5312        );
5313    }
5314
5315    #[test]
5316    fn sub_agent_context_config_default_is_empty() {
5317        let cx = SubAgentContextConfig::default();
5318        assert!(cx.todo_store.is_none());
5319        assert!(cx.context_recall_store.is_none());
5320        assert!(cx.context_window_tokens.is_none());
5321        assert!(!cx.replan_on_verify_fail);
5322    }
5323
5324    /// Captures the tail (last-message text) of every LLM request, for the
5325    /// multi-agent context-enablement tests.
5326    #[tokio::test(flavor = "multi_thread")]
5327    async fn entry_agent_goal_is_wired() {
5328        // P2 (completion-loop harness): a GoalCondition set on the builder must
5329        // reach the ENTRY runner — the judge gates natural completion and its
5330        // continuation message is injected when the goal is not yet met.
5331        let main = Arc::new(TailCapturingProvider {
5332            responses: Mutex::new(vec![text_end("first attempt"), text_end("final result")]),
5333            tails: Mutex::new(vec![]),
5334        });
5335        let judge = Arc::new(TailCapturingProvider {
5336            responses: Mutex::new(vec![
5337                text_end("GOAL_MET: NO: no concrete evidence of the endpoint"),
5338                text_end("GOAL_MET: YES"),
5339            ]),
5340            tails: Mutex::new(vec![]),
5341        });
5342        let mut orch = Orchestrator::builder(main.clone())
5343            .entry_agent(vec![])
5344            .entry_goal(
5345                crate::agent::goal::GoalCondition::new(
5346                    "ship the /health endpoint",
5347                    Arc::new(crate::llm::BoxedProvider::from_arc(judge)),
5348                )
5349                .with_max_continuations(2),
5350            )
5351            .sub_agent("worker", "does work", "you work")
5352            .build()
5353            .unwrap();
5354        let out = orch.run("add /health").await.unwrap();
5355        assert_eq!(out.result, "final result");
5356        let tails = main.tails.lock().unwrap();
5357        assert!(
5358            tails
5359                .iter()
5360                .any(|t| t.contains("objective is not yet complete")),
5361            "judge continuation must be injected into the entry agent: {tails:?}"
5362        );
5363    }
5364
5365    #[test]
5366    fn clarify_rule_forbids_re_asking_specified_facts() {
5367        let agents: Vec<(&str, &str, &[String])> = vec![];
5368        let p = build_entry_agent_prompt_ext(
5369            &agents,
5370            false,
5371            DispatchMode::Parallel,
5372            &[],
5373            EntryCaps {
5374                ask_user: true,
5375                ..Default::default()
5376            },
5377        );
5378        let low = p.to_lowercase();
5379        assert!(
5380            low.contains("never re-ask anything the user already specified"),
5381            "must forbid re-asking specified facts: {p}"
5382        );
5383        // A constraint is honored by RESOLVING it to where the file tools can
5384        // act — e.g. 'a temporary directory' → an in-workspace scratch subdir —
5385        // not re-litigated and not taken to an unwritable location (/tmp).
5386        assert!(
5387            low.contains("resolve a constraint")
5388                && low.contains("scratch subdirectory inside the workspace"),
5389            "constraints must be resolved to where the file tools can act: {p}"
5390        );
5391    }
5392
5393    #[test]
5394    fn entry_prompt_discipline_rules_gated_on_caps() {
5395        let agents: Vec<(&str, &str, &[String])> = vec![];
5396        let full = build_entry_agent_prompt_ext(
5397            &agents,
5398            false,
5399            DispatchMode::Parallel,
5400            &[],
5401            EntryCaps {
5402                ask_user: false,
5403                set_goal: true,
5404                set_scope: true,
5405            },
5406        );
5407        assert!(full.contains("set_goal"), "goal rule present: {full}");
5408        assert!(full.contains("set_scope"), "scope rule present: {full}");
5409        let none = build_entry_agent_prompt_ext(
5410            &agents,
5411            false,
5412            DispatchMode::Parallel,
5413            &[],
5414            EntryCaps::default(),
5415        );
5416        assert!(!none.contains("set_goal") && !none.contains("set_scope"));
5417    }
5418
5419    #[tokio::test(flavor = "multi_thread")]
5420    async fn entry_agent_set_goal_tool_installs_runtime_goal() {
5421        // The runtime half of judge-gated completion: with entry_goal_judge
5422        // set, the entry agent gets a `set_goal` tool sharing the runner's
5423        // goal slot — installing a goal mid-run makes the judge gate the very
5424        // next natural stop.
5425        let main = Arc::new(TailCapturingProvider {
5426            responses: Mutex::new(vec![
5427                CompletionResponse {
5428                    content: vec![ContentBlock::ToolUse {
5429                        id: "g1".into(),
5430                        name: "set_goal".into(),
5431                        input: json!({"objective": "demonstrate the endpoint works"}),
5432                    }],
5433                    stop_reason: StopReason::ToolUse,
5434                    reasoning: None,
5435                    usage: TokenUsage::default(),
5436                    model: None,
5437                },
5438                text_end("first stop"),  // judge says NO → continuation
5439                text_end("real result"), // judge says YES
5440            ]),
5441            tails: Mutex::new(vec![]),
5442        });
5443        let judge = Arc::new(TailCapturingProvider {
5444            responses: Mutex::new(vec![
5445                text_end("GOAL_MET: NO: nothing demonstrated"),
5446                text_end("GOAL_MET: YES"),
5447            ]),
5448            tails: Mutex::new(vec![]),
5449        });
5450        let mut orch = Orchestrator::builder(main.clone())
5451            .entry_agent(vec![])
5452            .entry_goal_judge(Arc::new(crate::llm::BoxedProvider::from_arc(judge.clone())))
5453            .sub_agent("worker", "does work", "you work")
5454            .max_turns(20)
5455            .build()
5456            .unwrap();
5457        let out = orch.run("ship the endpoint").await.unwrap();
5458        assert_eq!(out.result, "real result");
5459        assert_eq!(
5460            judge.tails.lock().unwrap().len(),
5461            2,
5462            "judge consulted at both stops"
5463        );
5464        let tails = main.tails.lock().unwrap();
5465        assert!(
5466            tails.iter().any(|t| t.contains("not yet complete")),
5467            "the runtime-installed goal gated the stop: {tails:?}"
5468        );
5469    }
5470
5471    #[tokio::test(flavor = "multi_thread")]
5472    async fn entry_agent_auto_wires_delegation_nudge() {
5473        // The entry agent grinds through ENTRY_DELEGATION_NUDGE_AFTER direct
5474        // tool calls without delegating → the runner must inject the one-shot
5475        // "[delegation check]" reminder (live trace evidence 2026-06-07: zero
5476        // organic delegations across ~30 sessions; prompt-only routing failed).
5477        struct NoopTool;
5478        impl Tool for NoopTool {
5479            fn definition(&self) -> ToolDefinition {
5480                ToolDefinition {
5481                    name: "noop".into(),
5482                    description: "no-op".into(),
5483                    input_schema: json!({"type": "object", "properties": {}}),
5484                }
5485            }
5486            fn execute(
5487                &self,
5488                _ctx: &crate::ExecutionContext,
5489                _input: serde_json::Value,
5490            ) -> std::pin::Pin<
5491                Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5492            > {
5493                Box::pin(async { Ok(ToolOutput::success("ok")) })
5494            }
5495        }
5496
5497        let mut responses: Vec<CompletionResponse> = (0..ENTRY_DELEGATION_NUDGE_AFTER)
5498            .map(|i| CompletionResponse {
5499                content: vec![ContentBlock::ToolUse {
5500                    id: format!("t{i}"),
5501                    name: "noop".into(),
5502                    input: json!({}),
5503                }],
5504                stop_reason: StopReason::ToolUse,
5505                reasoning: None,
5506                usage: TokenUsage::default(),
5507                model: None,
5508            })
5509            .collect();
5510        responses.push(text_end("done"));
5511        let provider = Arc::new(TailCapturingProvider {
5512            responses: Mutex::new(responses),
5513            tails: Mutex::new(vec![]),
5514        });
5515        let mut orch = Orchestrator::builder(provider.clone())
5516            .entry_agent(vec![Arc::new(NoopTool)])
5517            .sub_agent("worker", "does work", "you work")
5518            .max_turns(30)
5519            .build()
5520            .unwrap();
5521        let out = orch.run("a substantive task").await.unwrap();
5522        assert_eq!(out.result, "done");
5523        let tails = provider.tails.lock().unwrap();
5524        assert!(
5525            tails.iter().any(|t| t.contains("[delegation check]")),
5526            "the nudge must reach the model after {ENTRY_DELEGATION_NUDGE_AFTER} solo calls: {tails:?}"
5527        );
5528    }
5529
5530    struct TailCapturingProvider {
5531        responses: Mutex<Vec<CompletionResponse>>,
5532        tails: Mutex<Vec<String>>,
5533    }
5534    impl LlmProvider for TailCapturingProvider {
5535        async fn complete(
5536            &self,
5537            request: crate::llm::types::CompletionRequest,
5538        ) -> Result<CompletionResponse, crate::error::Error> {
5539            let tail = request
5540                .messages
5541                .last()
5542                .map(|m| {
5543                    m.content
5544                        .iter()
5545                        .filter_map(|b| match b {
5546                            ContentBlock::Text { text } => Some(text.as_str()),
5547                            _ => None,
5548                        })
5549                        .collect::<Vec<_>>()
5550                        .join("\n")
5551                })
5552                .unwrap_or_default();
5553            self.tails.lock().unwrap().push(tail);
5554            let mut r = self.responses.lock().unwrap();
5555            if r.is_empty() {
5556                return Err(crate::error::Error::Agent("no more responses".into()));
5557            }
5558            Ok(r.remove(0))
5559        }
5560    }
5561
5562    fn text_end(t: &str) -> CompletionResponse {
5563        CompletionResponse {
5564            content: vec![ContentBlock::Text { text: t.into() }],
5565            stop_reason: StopReason::EndTurn,
5566            reasoning: None,
5567            usage: TokenUsage::default(),
5568            model: None,
5569        }
5570    }
5571
5572    fn delegate_then_finish() -> Vec<CompletionResponse> {
5573        vec![
5574            // 1: orchestrator delegates to "worker"
5575            CompletionResponse {
5576                content: vec![ContentBlock::ToolUse {
5577                    id: "c1".into(),
5578                    name: "delegate_task".into(),
5579                    input: json!({"tasks": [{"agent": "worker", "task": "do work"}]}),
5580                }],
5581                stop_reason: StopReason::ToolUse,
5582                reasoning: None,
5583                usage: TokenUsage::default(),
5584                model: None,
5585            },
5586            // 2: the sub-agent finishes
5587            CompletionResponse {
5588                content: vec![ContentBlock::Text {
5589                    text: "done".into(),
5590                }],
5591                stop_reason: StopReason::EndTurn,
5592                reasoning: None,
5593                usage: TokenUsage::default(),
5594                model: None,
5595            },
5596            // 3: orchestrator synthesis
5597            CompletionResponse {
5598                content: vec![ContentBlock::Text {
5599                    text: "synth".into(),
5600                }],
5601                stop_reason: StopReason::EndTurn,
5602                reasoning: None,
5603                usage: TokenUsage::default(),
5604                model: None,
5605            },
5606        ]
5607    }
5608
5609    // Multi-agent enablement: a sub-agent whose `context.todo_store` has open
5610    // items recites the plan at its OWN context tail when the orchestrator
5611    // delegates to it.
5612    #[tokio::test]
5613    async fn recitation_propagates_to_delegated_sub_agent() {
5614        let store = Arc::new(crate::tool::builtins::TodoStore::new());
5615        let tools = crate::tool::builtins::todo_tools(store.clone());
5616        tools
5617            .iter()
5618            .find(|t| t.definition().name == "todowrite")
5619            .unwrap()
5620            .execute(
5621                &crate::ExecutionContext::default(),
5622                json!({"todos": [{"content": "finish the parser", "status": "in_progress", "priority": "high"}]}),
5623            )
5624            .await
5625            .unwrap();
5626
5627        let provider = Arc::new(TailCapturingProvider {
5628            responses: Mutex::new(delegate_then_finish()),
5629            tails: Mutex::new(vec![]),
5630        });
5631        let cfg = SubAgentConfig {
5632            name: "worker".into(),
5633            description: "Worker".into(),
5634            system_prompt: "You work.".into(),
5635            context: SubAgentContextConfig {
5636                todo_store: Some(store),
5637                ..Default::default()
5638            },
5639            ..Default::default()
5640        };
5641        let mut orch = Orchestrator::builder(provider.clone())
5642            .sub_agent_full(cfg)
5643            .build()
5644            .unwrap();
5645        orch.run("do work").await.unwrap();
5646
5647        let tails = provider.tails.lock().unwrap();
5648        assert!(
5649            tails.iter().any(|t| t.contains("[plan — open items")),
5650            "the delegated sub-agent must recite its plan; tails: {tails:?}"
5651        );
5652        assert!(
5653            tails.iter().any(|t| t.contains("[>] finish the parser")),
5654            "recitation should carry the open item; tails: {tails:?}"
5655        );
5656    }
5657
5658    // Regression: a sub-agent with a DEFAULT context recites nothing (existing
5659    // behavior unchanged).
5660    #[tokio::test]
5661    async fn default_context_means_no_recitation_for_sub_agent() {
5662        let provider = Arc::new(TailCapturingProvider {
5663            responses: Mutex::new(delegate_then_finish()),
5664            tails: Mutex::new(vec![]),
5665        });
5666        let cfg = SubAgentConfig {
5667            name: "worker".into(),
5668            description: "Worker".into(),
5669            system_prompt: "You work.".into(),
5670            ..Default::default() // context defaults to empty
5671        };
5672        let mut orch = Orchestrator::builder(provider.clone())
5673            .sub_agent_full(cfg)
5674            .build()
5675            .unwrap();
5676        orch.run("do work").await.unwrap();
5677
5678        let tails = provider.tails.lock().unwrap();
5679        assert!(
5680            !tails.iter().any(|t| t.contains("[plan — open items")),
5681            "default-context sub-agent must not recite; tails: {tails:?}"
5682        );
5683    }
5684
5685    // Multi-agent enablement: the form_squad path forwards context too. F-AGENT-2
5686    // proved this path can silently diverge from delegate_task in THIS file, so
5687    // it gets its own test (not "the blocks are identical, one test covers it").
5688    #[tokio::test]
5689    async fn recitation_propagates_to_form_squad_member() {
5690        let store = Arc::new(crate::tool::builtins::TodoStore::new());
5691        let tools = crate::tool::builtins::todo_tools(store.clone());
5692        tools
5693            .iter()
5694            .find(|t| t.definition().name == "todowrite")
5695            .unwrap()
5696            .execute(
5697                &crate::ExecutionContext::default(),
5698                json!({"todos": [{"content": "ship the feature", "status": "in_progress", "priority": "high"}]}),
5699            )
5700            .await
5701            .unwrap();
5702
5703        let provider = Arc::new(TailCapturingProvider {
5704            responses: Mutex::new(vec![
5705                // 1: orchestrator forms a 2-member squad (form_squad needs ≥2).
5706                CompletionResponse {
5707                    content: vec![ContentBlock::ToolUse {
5708                        id: "c1".into(),
5709                        name: "form_squad".into(),
5710                        input: json!({"tasks": [
5711                            {"agent": "worker", "task": "do A"},
5712                            {"agent": "helper", "task": "do B"}
5713                        ]}),
5714                    }],
5715                    stop_reason: StopReason::ToolUse,
5716                    reasoning: None,
5717                    usage: TokenUsage::default(),
5718                    model: None,
5719                },
5720                text_end("A done"),
5721                text_end("B done"),
5722                text_end("synth"),
5723            ]),
5724            tails: Mutex::new(vec![]),
5725        });
5726        // Only "worker" carries a populated todo store.
5727        let worker = SubAgentConfig {
5728            name: "worker".into(),
5729            description: "Worker".into(),
5730            system_prompt: "You work.".into(),
5731            context: SubAgentContextConfig {
5732                todo_store: Some(store),
5733                ..Default::default()
5734            },
5735            ..Default::default()
5736        };
5737        let helper = SubAgentConfig {
5738            name: "helper".into(),
5739            description: "Helper".into(),
5740            system_prompt: "You help.".into(),
5741            ..Default::default()
5742        };
5743        let mut orch = Orchestrator::builder(provider.clone())
5744            .sub_agent_full(worker)
5745            .sub_agent_full(helper)
5746            .build()
5747            .unwrap();
5748        orch.run("do work").await.unwrap();
5749
5750        let tails = provider.tails.lock().unwrap();
5751        assert!(
5752            tails.iter().any(|t| t.contains("[>] ship the feature")),
5753            "a form_squad member must recite its plan; tails: {tails:?}"
5754        );
5755    }
5756
5757    // Multi-agent enablement: `replan_on_verify_fail` must also propagate — a
5758    // delegated sub-agent whose verify is RED gets the corrective nudge re-injected
5759    // (instead of finishing on red).
5760    #[tokio::test]
5761    async fn replan_propagates_to_delegated_sub_agent() {
5762        struct FailVerify;
5763        impl Tool for FailVerify {
5764            fn definition(&self) -> ToolDefinition {
5765                ToolDefinition {
5766                    name: "verify".into(),
5767                    description: "verify".into(),
5768                    input_schema: json!({"type": "object", "properties": {}}),
5769                }
5770            }
5771            fn execute(
5772                &self,
5773                _ctx: &crate::ExecutionContext,
5774                _input: serde_json::Value,
5775            ) -> std::pin::Pin<
5776                Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
5777            > {
5778                Box::pin(async {
5779                    Ok(ToolOutput::success(
5780                        "VERIFY_RESULT: FAIL exit_code=1 command=cargo test".to_string(),
5781                    ))
5782                })
5783            }
5784        }
5785
5786        let provider = Arc::new(TailCapturingProvider {
5787            responses: Mutex::new(vec![
5788                // 1: orchestrator delegates
5789                CompletionResponse {
5790                    content: vec![ContentBlock::ToolUse {
5791                        id: "c1".into(),
5792                        name: "delegate_task".into(),
5793                        input: json!({"tasks": [{"agent": "worker", "task": "do work"}]}),
5794                    }],
5795                    stop_reason: StopReason::ToolUse,
5796                    reasoning: None,
5797                    usage: TokenUsage::default(),
5798                    model: None,
5799                },
5800                // 2: sub-agent runs verify (→ RED)
5801                CompletionResponse {
5802                    content: vec![ContentBlock::ToolUse {
5803                        id: "v1".into(),
5804                        name: "verify".into(),
5805                        input: json!({}),
5806                    }],
5807                    stop_reason: StopReason::ToolUse,
5808                    reasoning: None,
5809                    usage: TokenUsage::default(),
5810                    model: None,
5811                },
5812                // 3..: sub-agent tries to finish (RED → replan nudge), then more
5813                text_end("done"),
5814                text_end("done"),
5815                text_end("synth"),
5816            ]),
5817            tails: Mutex::new(vec![]),
5818        });
5819        let worker = SubAgentConfig {
5820            name: "worker".into(),
5821            description: "Worker".into(),
5822            system_prompt: "You work.".into(),
5823            tools: vec![Arc::new(FailVerify)],
5824            context: SubAgentContextConfig {
5825                replan_on_verify_fail: true,
5826                ..Default::default()
5827            },
5828            ..Default::default()
5829        };
5830        let mut orch = Orchestrator::builder(provider.clone())
5831            .sub_agent_full(worker)
5832            .build()
5833            .unwrap();
5834        let _ = orch.run("do work").await;
5835
5836        let tails = provider.tails.lock().unwrap();
5837        assert!(
5838            tails.iter().any(|t| t.contains("Verification is RED")),
5839            "the delegated sub-agent must get the replan nudge on a RED verify; tails: {tails:?}"
5840        );
5841    }
5842
5843    #[tokio::test]
5844    async fn orchestrator_guardrails_propagate_to_form_squad_members() {
5845        // SECURITY (F-AGENT-2): the form_squad path must combine orchestrator
5846        // guardrails with each member's own, exactly like delegate_task.
5847        use crate::agent::guardrail::Guardrail;
5848        use crate::llm::types::CompletionRequest;
5849
5850        struct MarkerGuardrail;
5851        impl Guardrail for MarkerGuardrail {
5852            fn pre_llm(
5853                &self,
5854                request: &mut CompletionRequest,
5855            ) -> std::pin::Pin<
5856                Box<dyn std::future::Future<Output = Result<(), crate::error::Error>> + Send + '_>,
5857            > {
5858                request.system = format!("{} [ORCH_GUARD_ACTIVE]", request.system);
5859                Box::pin(async { Ok(()) })
5860            }
5861        }
5862        struct CapturingProvider {
5863            responses: Mutex<Vec<CompletionResponse>>,
5864            systems_seen: Mutex<Vec<String>>,
5865        }
5866        impl LlmProvider for CapturingProvider {
5867            async fn complete(
5868                &self,
5869                request: CompletionRequest,
5870            ) -> Result<CompletionResponse, crate::error::Error> {
5871                self.systems_seen
5872                    .lock()
5873                    .unwrap()
5874                    .push(request.system.clone());
5875                let mut r = self.responses.lock().unwrap();
5876                if r.is_empty() {
5877                    return Err(crate::error::Error::Agent("no more responses".into()));
5878                }
5879                Ok(r.remove(0))
5880            }
5881        }
5882        fn text(t: &str) -> CompletionResponse {
5883            CompletionResponse {
5884                content: vec![ContentBlock::Text { text: t.into() }],
5885                stop_reason: StopReason::EndTurn,
5886                reasoning: None,
5887                usage: TokenUsage::default(),
5888                model: None,
5889            }
5890        }
5891        fn bare_sub_agent(name: &str) -> SubAgentConfig {
5892            SubAgentConfig {
5893                name: name.into(),
5894                description: format!("{name} agent"),
5895                system_prompt: "You work.".into(),
5896                tools: vec![],
5897                context_strategy: None,
5898                summarize_threshold: None,
5899                tool_timeout: None,
5900                max_tool_output_bytes: None,
5901                max_turns: None,
5902                max_tokens: None,
5903                response_schema: None,
5904                run_timeout: None,
5905                guardrails: vec![], // NO member guardrails — protection comes only from the orchestrator
5906                provider: None,
5907                reasoning_effort: None,
5908                enable_reflection: None,
5909                tool_output_compression_threshold: None,
5910                max_tools_per_turn: None,
5911                tool_profile: None,
5912                max_identical_tool_calls: None,
5913                max_fuzzy_identical_tool_calls: None,
5914                max_tool_calls_per_turn: None,
5915                session_prune_config: None,
5916                enable_recursive_summarization: None,
5917                reflection_threshold: None,
5918                consolidate_on_exit: None,
5919                workspace: None,
5920                max_total_tokens: None,
5921                audit_trail: None,
5922                audit_user_id: None,
5923                audit_tenant_id: None,
5924                audit_delegation_chain: Vec::new(),
5925                context: Default::default(),
5926            }
5927        }
5928
5929        let provider = Arc::new(CapturingProvider {
5930            responses: Mutex::new(vec![
5931                // 1: orchestrator forms a 2-member squad
5932                CompletionResponse {
5933                    content: vec![ContentBlock::ToolUse {
5934                        id: "call-1".into(),
5935                        name: "form_squad".into(),
5936                        input: json!({"tasks": [
5937                            {"agent": "worker", "task": "do A"},
5938                            {"agent": "helper", "task": "do B"}
5939                        ]}),
5940                    }],
5941                    stop_reason: StopReason::ToolUse,
5942                    reasoning: None,
5943                    usage: TokenUsage::default(),
5944                    model: None,
5945                },
5946                text("A done."),      // 2: squad member
5947                text("B done."),      // 3: squad member
5948                text("Synthesized."), // 4: orchestrator synthesis
5949            ]),
5950            systems_seen: Mutex::new(vec![]),
5951        });
5952
5953        let guardrail: Arc<dyn Guardrail> = Arc::new(MarkerGuardrail);
5954        let mut orch = Orchestrator::builder(provider.clone())
5955            .guardrail(guardrail)
5956            .sub_agent_full(bare_sub_agent("worker"))
5957            .sub_agent_full(bare_sub_agent("helper"))
5958            .build()
5959            .unwrap();
5960        orch.run("do work").await.unwrap();
5961
5962        let systems = provider.systems_seen.lock().unwrap();
5963        // At least one squad-member call must carry the orchestrator guardrail marker.
5964        assert!(
5965            systems
5966                .iter()
5967                .skip(1)
5968                .any(|s| s.contains("[ORCH_GUARD_ACTIVE]")),
5969            "a form_squad member's system prompt must contain the orchestrator guardrail marker; got: {systems:?}"
5970        );
5971    }
5972
5973    /// SECURITY (audit 2026-06-09, HIGH): the human-approval gate must follow
5974    /// delegated work. Before the fix, sub-agent runners were built without
5975    /// the orchestrator's `on_approval` callback, so every sub-agent tool call
5976    /// was silently auto-allowed (an `Ask` permission rule with no callback
5977    /// falls into the runner's "no callback → allow" branch) — one approved
5978    /// `delegate_task` call bypassed the whole human gate, including Plan
5979    /// mode's read-only promise.
5980    #[tokio::test]
5981    async fn on_approval_propagates_to_delegated_sub_agents() {
5982        let (danger_tool, executed) = TrackedTool::make("danger_tool");
5983        let (approval, seen) = deny_danger_approval();
5984
5985        let provider = Arc::new(MockProvider::new(vec![
5986            // 1: orchestrator delegates (gate fires for delegate_task → Allow)
5987            CompletionResponse {
5988                content: vec![ContentBlock::ToolUse {
5989                    id: "call-1".into(),
5990                    name: "delegate_task".into(),
5991                    input: json!({"tasks": [{"agent": "worker", "task": "mutate"}]}),
5992                }],
5993                stop_reason: StopReason::ToolUse,
5994                reasoning: None,
5995                usage: TokenUsage::default(),
5996                model: None,
5997            },
5998            // 2: sub-agent calls the dangerous tool (gate must fire → Deny)
5999            CompletionResponse {
6000                content: vec![ContentBlock::ToolUse {
6001                    id: "call-2".into(),
6002                    name: "danger_tool".into(),
6003                    input: json!({}),
6004                }],
6005                stop_reason: StopReason::ToolUse,
6006                reasoning: None,
6007                usage: TokenUsage::default(),
6008                model: None,
6009            },
6010            // 3: sub-agent gives up after the deny
6011            CompletionResponse {
6012                content: vec![ContentBlock::Text {
6013                    text: "stopped".into(),
6014                }],
6015                stop_reason: StopReason::EndTurn,
6016                reasoning: None,
6017                usage: TokenUsage::default(),
6018                model: None,
6019            },
6020            // 4: orchestrator synthesis
6021            CompletionResponse {
6022                content: vec![ContentBlock::Text {
6023                    text: "done".into(),
6024                }],
6025                stop_reason: StopReason::EndTurn,
6026                reasoning: None,
6027                usage: TokenUsage::default(),
6028                model: None,
6029            },
6030        ]));
6031
6032        let mut orch = Orchestrator::builder(provider.clone())
6033            .on_approval(approval)
6034            .sub_agent_full(SubAgentConfig {
6035                name: "worker".into(),
6036                description: "Worker agent".into(),
6037                system_prompt: "You work.".into(),
6038                tools: vec![danger_tool],
6039                ..Default::default()
6040            })
6041            .build()
6042            .unwrap();
6043
6044        let output = orch.run("do work").await.unwrap();
6045        assert_eq!(output.result, "done");
6046
6047        let names = seen.lock().expect("seen lock").clone();
6048        assert!(
6049            names.iter().any(|n| n == "danger_tool"),
6050            "approval callback must fire for the SUB-AGENT's tool calls; saw: {names:?}"
6051        );
6052        assert!(
6053            !executed.load(std::sync::atomic::Ordering::SeqCst),
6054            "human-denied tool must NOT execute in the sub-agent"
6055        );
6056        // The deny must be visible to the sub-agent as a tool error.
6057        let requests = provider.captured_requests.lock().expect("capture lock");
6058        let denied_seen = requests.iter().any(|r| {
6059            r.messages.iter().any(|m| {
6060                m.content.iter().any(|b| match b {
6061                    ContentBlock::ToolResult { content, .. } => {
6062                        content.contains("denied by human reviewer")
6063                    }
6064                    _ => false,
6065                })
6066            })
6067        });
6068        assert!(
6069            denied_seen,
6070            "sub-agent must see the human-reviewer denial as a tool result"
6071        );
6072    }
6073
6074    /// SECURITY (audit 2026-06-09): same human-approval propagation for the
6075    /// `form_squad` path — squad members' tool calls must be gated by the
6076    /// orchestrator's `on_approval` callback too.
6077    #[tokio::test]
6078    async fn on_approval_propagates_to_form_squad_members() {
6079        let (danger_tool, executed) = TrackedTool::make("danger_tool");
6080        let (approval, seen) = deny_danger_approval();
6081
6082        fn text(t: &str) -> CompletionResponse {
6083            CompletionResponse {
6084                content: vec![ContentBlock::Text { text: t.into() }],
6085                stop_reason: StopReason::EndTurn,
6086                reasoning: None,
6087                usage: TokenUsage::default(),
6088                model: None,
6089            }
6090        }
6091        fn danger_call(id: &str) -> CompletionResponse {
6092            CompletionResponse {
6093                content: vec![ContentBlock::ToolUse {
6094                    id: id.into(),
6095                    name: "danger_tool".into(),
6096                    input: json!({}),
6097                }],
6098                stop_reason: StopReason::ToolUse,
6099                reasoning: None,
6100                usage: TokenUsage::default(),
6101                model: None,
6102            }
6103        }
6104
6105        let provider = Arc::new(MockProvider::new(vec![
6106            // 1: orchestrator forms a 2-member squad (gate fires → Allow)
6107            CompletionResponse {
6108                content: vec![ContentBlock::ToolUse {
6109                    id: "call-1".into(),
6110                    name: "form_squad".into(),
6111                    input: json!({"tasks": [
6112                        {"agent": "worker", "task": "do A"},
6113                        {"agent": "helper", "task": "do B"}
6114                    ]}),
6115                }],
6116                stop_reason: StopReason::ToolUse,
6117                reasoning: None,
6118                usage: TokenUsage::default(),
6119                model: None,
6120            },
6121            // 2-3: both members try the dangerous tool (gate must fire → Deny)
6122            danger_call("call-a"),
6123            danger_call("call-b"),
6124            // 4-5: both members give up after the deny
6125            text("stopped"),
6126            text("stopped"),
6127            // 6: orchestrator synthesis
6128            text("done"),
6129        ]));
6130
6131        let mut orch = Orchestrator::builder(provider.clone())
6132            .on_approval(approval)
6133            .enable_squads(true)
6134            .sub_agent_full(SubAgentConfig {
6135                name: "worker".into(),
6136                description: "Worker agent".into(),
6137                system_prompt: "You work.".into(),
6138                tools: vec![danger_tool.clone()],
6139                ..Default::default()
6140            })
6141            .sub_agent_full(SubAgentConfig {
6142                name: "helper".into(),
6143                description: "Helper agent".into(),
6144                system_prompt: "You help.".into(),
6145                tools: vec![danger_tool],
6146                ..Default::default()
6147            })
6148            .build()
6149            .unwrap();
6150
6151        let output = orch.run("do work").await.unwrap();
6152        assert_eq!(output.result, "done");
6153
6154        let names = seen.lock().expect("seen lock").clone();
6155        assert!(
6156            names.iter().any(|n| n == "danger_tool"),
6157            "approval callback must fire for squad members' tool calls; saw: {names:?}"
6158        );
6159        assert!(
6160            !executed.load(std::sync::atomic::Ordering::SeqCst),
6161            "human-denied tool must NOT execute in any squad member"
6162        );
6163    }
6164
6165    /// SECURITY (audit 2026-06-09): same human-approval propagation for the
6166    /// `spawn_agent` path — dynamically spawned agents inherit the gate.
6167    #[tokio::test]
6168    async fn spawn_agent_forwards_on_approval() {
6169        let (danger_tool, executed) = TrackedTool::make("danger_tool");
6170        let (approval, seen) = deny_danger_approval();
6171
6172        let provider = Arc::new(MockProvider::new(vec![
6173            // 1: spawned agent tries the dangerous tool (gate must fire → Deny)
6174            CompletionResponse {
6175                content: vec![ContentBlock::ToolUse {
6176                    id: "call-1".into(),
6177                    name: "danger_tool".into(),
6178                    input: json!({}),
6179                }],
6180                stop_reason: StopReason::ToolUse,
6181                reasoning: None,
6182                usage: TokenUsage::default(),
6183                model: None,
6184            },
6185            // 2: spawned agent gives up after the deny
6186            CompletionResponse {
6187                content: vec![ContentBlock::Text {
6188                    text: "stopped".into(),
6189                }],
6190                stop_reason: StopReason::EndTurn,
6191                reasoning: None,
6192                usage: TokenUsage::default(),
6193                model: None,
6194            },
6195        ]));
6196
6197        let mut config = make_spawn_config();
6198        config.tool_allowlist = vec!["danger_tool".into()];
6199        let mut tool = build_spawn_tool(provider, config, vec![danger_tool]);
6200        tool.on_approval = Some(approval);
6201
6202        let result = tool
6203            .spawn(SpawnAgentInput {
6204                name: "mutator".into(),
6205                system_prompt: "You mutate.".into(),
6206                tools: vec!["danger_tool".into()],
6207                task: "mutate".into(),
6208            })
6209            .await
6210            .unwrap();
6211
6212        assert!(!result.is_error);
6213        let names = seen.lock().expect("seen lock").clone();
6214        assert!(
6215            names.iter().any(|n| n == "danger_tool"),
6216            "approval callback must fire for the spawned agent's tool calls; saw: {names:?}"
6217        );
6218        assert!(
6219            !executed.load(std::sync::atomic::Ordering::SeqCst),
6220            "human-denied tool must NOT execute in the spawned agent"
6221        );
6222    }
6223
6224    #[test]
6225    fn build_rejects_sub_agent_with_zero_max_turns() {
6226        let provider = Arc::new(MockProvider::new(vec![]));
6227        let result = Orchestrator::builder(provider)
6228            .sub_agent_full(SubAgentConfig {
6229                name: "agent1".into(),
6230                description: "Test agent".into(),
6231                system_prompt: "prompt".into(),
6232                tools: vec![],
6233                context_strategy: None,
6234                summarize_threshold: None,
6235                tool_timeout: None,
6236                max_tool_output_bytes: None,
6237                max_turns: Some(0),
6238                max_tokens: None,
6239                response_schema: None,
6240                run_timeout: None,
6241                guardrails: vec![],
6242                provider: None,
6243                reasoning_effort: None,
6244                enable_reflection: None,
6245                tool_output_compression_threshold: None,
6246                max_tools_per_turn: None,
6247                tool_profile: None,
6248                max_identical_tool_calls: None,
6249                max_fuzzy_identical_tool_calls: None,
6250                max_tool_calls_per_turn: None,
6251                session_prune_config: None,
6252                enable_recursive_summarization: None,
6253                reflection_threshold: None,
6254                consolidate_on_exit: None,
6255                workspace: None,
6256                max_total_tokens: None,
6257                audit_trail: None,
6258                audit_user_id: None,
6259                audit_tenant_id: None,
6260                audit_delegation_chain: Vec::new(),
6261                context: Default::default(),
6262            })
6263            .build();
6264
6265        match result {
6266            Err(e) => assert!(
6267                e.to_string().contains("max_turns must be > 0"),
6268                "expected max_turns error, got: {e}"
6269            ),
6270            Ok(_) => panic!("expected build to fail with zero max_turns"),
6271        }
6272    }
6273
6274    #[test]
6275    fn build_rejects_sub_agent_with_zero_max_tokens() {
6276        let provider = Arc::new(MockProvider::new(vec![]));
6277        let result = Orchestrator::builder(provider)
6278            .sub_agent_full(SubAgentConfig {
6279                name: "agent1".into(),
6280                description: "Test agent".into(),
6281                system_prompt: "prompt".into(),
6282                tools: vec![],
6283                context_strategy: None,
6284                summarize_threshold: None,
6285                tool_timeout: None,
6286                max_tool_output_bytes: None,
6287                max_turns: None,
6288                max_tokens: Some(0),
6289                response_schema: None,
6290                run_timeout: None,
6291                guardrails: vec![],
6292                provider: None,
6293                reasoning_effort: None,
6294                enable_reflection: None,
6295                tool_output_compression_threshold: None,
6296                max_tools_per_turn: None,
6297                tool_profile: None,
6298                max_identical_tool_calls: None,
6299                max_fuzzy_identical_tool_calls: None,
6300                max_tool_calls_per_turn: None,
6301                session_prune_config: None,
6302                enable_recursive_summarization: None,
6303                reflection_threshold: None,
6304                consolidate_on_exit: None,
6305                workspace: None,
6306                max_total_tokens: None,
6307                audit_trail: None,
6308                audit_user_id: None,
6309                audit_tenant_id: None,
6310                audit_delegation_chain: Vec::new(),
6311                context: Default::default(),
6312            })
6313            .build();
6314
6315        match result {
6316            Err(e) => assert!(
6317                e.to_string().contains("max_tokens must be > 0"),
6318                "expected max_tokens error, got: {e}"
6319            ),
6320            Ok(_) => panic!("expected build to fail with zero max_tokens"),
6321        }
6322    }
6323
6324    #[tokio::test]
6325    async fn sub_agent_uses_override_provider() {
6326        use crate::llm::types::CompletionRequest;
6327
6328        // Provider that returns a model identifier in the response text
6329        struct IdentifiedProvider {
6330            id: String,
6331            responses: Mutex<Vec<CompletionResponse>>,
6332        }
6333
6334        impl LlmProvider for IdentifiedProvider {
6335            async fn complete(
6336                &self,
6337                _request: CompletionRequest,
6338            ) -> Result<CompletionResponse, Error> {
6339                let mut responses = self.responses.lock().expect("lock");
6340                if responses.is_empty() {
6341                    return Err(Error::Agent(format!("no more responses for {}", self.id)));
6342                }
6343                Ok(responses.remove(0))
6344            }
6345        }
6346
6347        // Orchestrator uses "opus" provider, sub-agent overrides with "haiku" provider
6348        let opus_provider = Arc::new(IdentifiedProvider {
6349            id: "opus".into(),
6350            responses: Mutex::new(vec![
6351                // 1: Orchestrator delegates
6352                CompletionResponse {
6353                    content: vec![ContentBlock::ToolUse {
6354                        id: "call-1".into(),
6355                        name: "delegate_task".into(),
6356                        input: json!({
6357                            "tasks": [{"agent": "cheap", "task": "do cheap work"}]
6358                        }),
6359                    }],
6360                    stop_reason: StopReason::ToolUse,
6361                    reasoning: None,
6362                    usage: TokenUsage::default(),
6363                    model: None,
6364                },
6365                // 3: Orchestrator synthesis
6366                CompletionResponse {
6367                    content: vec![ContentBlock::Text {
6368                        text: "Done.".into(),
6369                    }],
6370                    stop_reason: StopReason::EndTurn,
6371                    reasoning: None,
6372                    usage: TokenUsage::default(),
6373                    model: None,
6374                },
6375            ]),
6376        });
6377
6378        let haiku_provider: Arc<BoxedProvider> = Arc::new(BoxedProvider::new(IdentifiedProvider {
6379            id: "haiku".into(),
6380            responses: Mutex::new(vec![
6381                // 2: Sub-agent responds via haiku
6382                CompletionResponse {
6383                    content: vec![ContentBlock::Text {
6384                        text: "Cheap work done.".into(),
6385                    }],
6386                    stop_reason: StopReason::EndTurn,
6387                    reasoning: None,
6388                    usage: TokenUsage {
6389                        input_tokens: 5,
6390                        output_tokens: 3,
6391                        ..Default::default()
6392                    },
6393                    model: None,
6394                },
6395            ]),
6396        }));
6397
6398        let mut orch = Orchestrator::builder(opus_provider)
6399            .sub_agent_full(SubAgentConfig {
6400                name: "cheap".into(),
6401                description: "Cheap agent".into(),
6402                system_prompt: "You do cheap work.".into(),
6403                tools: vec![],
6404                context_strategy: None,
6405                summarize_threshold: None,
6406                tool_timeout: None,
6407                max_tool_output_bytes: None,
6408                max_turns: None,
6409                max_tokens: None,
6410                response_schema: None,
6411                run_timeout: None,
6412                guardrails: vec![],
6413                provider: Some(haiku_provider),
6414                reasoning_effort: None,
6415                enable_reflection: None,
6416                tool_output_compression_threshold: None,
6417                max_tools_per_turn: None,
6418                tool_profile: None,
6419                max_identical_tool_calls: None,
6420                max_fuzzy_identical_tool_calls: None,
6421                max_tool_calls_per_turn: None,
6422                session_prune_config: None,
6423                enable_recursive_summarization: None,
6424                reflection_threshold: None,
6425                consolidate_on_exit: None,
6426                workspace: None,
6427                max_total_tokens: None,
6428                audit_trail: None,
6429                audit_user_id: None,
6430                audit_tenant_id: None,
6431                audit_delegation_chain: Vec::new(),
6432                context: Default::default(),
6433            })
6434            .build()
6435            .unwrap();
6436
6437        let output = orch.run("do work cheaply").await.unwrap();
6438        assert_eq!(output.result, "Done.");
6439        // Sub-agent tokens should be accumulated from the haiku provider
6440        assert_eq!(output.tokens_used.input_tokens, 5);
6441    }
6442
6443    #[tokio::test]
6444    async fn sub_agent_inherits_default_provider() {
6445        // When no override is set, sub-agent uses the orchestrator's provider
6446        let provider = Arc::new(MockProvider::new(vec![
6447            // 1: Orchestrator delegates
6448            CompletionResponse {
6449                content: vec![ContentBlock::ToolUse {
6450                    id: "call-1".into(),
6451                    name: "delegate_task".into(),
6452                    input: json!({
6453                        "tasks": [{"agent": "worker", "task": "do work"}]
6454                    }),
6455                }],
6456                stop_reason: StopReason::ToolUse,
6457                reasoning: None,
6458                usage: TokenUsage::default(),
6459                model: None,
6460            },
6461            // 2: Sub-agent responds (from shared provider)
6462            CompletionResponse {
6463                content: vec![ContentBlock::Text {
6464                    text: "Work done.".into(),
6465                }],
6466                stop_reason: StopReason::EndTurn,
6467                reasoning: None,
6468                usage: TokenUsage::default(),
6469                model: None,
6470            },
6471            // 3: Orchestrator synthesis
6472            CompletionResponse {
6473                content: vec![ContentBlock::Text {
6474                    text: "All done.".into(),
6475                }],
6476                stop_reason: StopReason::EndTurn,
6477                reasoning: None,
6478                usage: TokenUsage::default(),
6479                model: None,
6480            },
6481        ]));
6482
6483        let mut orch = Orchestrator::builder(provider)
6484            .sub_agent_full(SubAgentConfig {
6485                name: "worker".into(),
6486                description: "Worker".into(),
6487                system_prompt: "Work.".into(),
6488                tools: vec![],
6489                context_strategy: None,
6490                summarize_threshold: None,
6491                tool_timeout: None,
6492                max_tool_output_bytes: None,
6493                max_turns: None,
6494                max_tokens: None,
6495                response_schema: None,
6496                run_timeout: None,
6497                guardrails: vec![],
6498                provider: None,
6499                reasoning_effort: None,
6500                enable_reflection: None,
6501                tool_output_compression_threshold: None,
6502                max_tools_per_turn: None,
6503                tool_profile: None,
6504                max_identical_tool_calls: None,
6505                max_fuzzy_identical_tool_calls: None,
6506                max_tool_calls_per_turn: None,
6507                session_prune_config: None,
6508                enable_recursive_summarization: None,
6509                reflection_threshold: None,
6510                consolidate_on_exit: None,
6511                workspace: None,
6512                max_total_tokens: None,
6513                audit_trail: None,
6514                audit_user_id: None,
6515                audit_tenant_id: None,
6516                audit_delegation_chain: Vec::new(),
6517                context: Default::default(),
6518            })
6519            .build()
6520            .unwrap();
6521
6522        let output = orch.run("do work").await.unwrap();
6523        assert_eq!(output.result, "All done.");
6524    }
6525
6526    // --- FormSquadTool tests ---
6527
6528    #[test]
6529    fn form_squad_tool_definition_schema() {
6530        let tools = vec!["web_search".to_string()];
6531        let agents: Vec<(&str, &str, &[String])> = vec![
6532            ("researcher", "Research specialist", tools.as_slice()),
6533            ("analyst", "Analysis expert", &[]),
6534        ];
6535        let def = build_form_squad_tool_schema(&agents);
6536        assert_eq!(def.name, "form_squad");
6537        assert!(
6538            def.description.contains("researcher"),
6539            "description should list agents: {}",
6540            def.description
6541        );
6542        assert!(
6543            def.description.contains("analyst"),
6544            "description should list agents: {}",
6545            def.description
6546        );
6547        assert!(
6548            def.description.contains("blackboard"),
6549            "description should mention shared blackboard: {}",
6550            def.description
6551        );
6552        assert!(
6553            def.description.contains("Unlike delegate_task"),
6554            "description should contrast with delegate_task: {}",
6555            def.description
6556        );
6557        // Check input schema uses tasks array (same format as delegate_task)
6558        assert_eq!(
6559            def.input_schema["properties"]["tasks"]["type"], "array",
6560            "schema should have tasks array"
6561        );
6562        assert_eq!(
6563            def.input_schema["properties"]["tasks"]["items"]["properties"]["agent"]["type"],
6564            "string",
6565            "tasks items should have agent field"
6566        );
6567        assert_eq!(
6568            def.input_schema["properties"]["tasks"]["items"]["properties"]["task"]["type"],
6569            "string",
6570            "tasks items should have task field"
6571        );
6572        let required = def.input_schema["required"]
6573            .as_array()
6574            .expect("required should be array");
6575        assert!(
6576            required.contains(&json!("tasks")),
6577            "tasks should be required"
6578        );
6579    }
6580
6581    #[tokio::test]
6582    async fn form_squad_dispatches_directly() {
6583        // 3 agents: researcher, analyst, coder. Squad of researcher + analyst.
6584        // No squad-leader LLM calls — agents are dispatched directly.
6585        let provider = Arc::new(MockProvider::new(vec![
6586            // 1: Outer orchestrator calls form_squad with per-agent tasks
6587            CompletionResponse {
6588                content: vec![ContentBlock::ToolUse {
6589                    id: "call-1".into(),
6590                    name: "form_squad".into(),
6591                    input: json!({
6592                        "tasks": [
6593                            {"agent": "researcher", "task": "Research Rust"},
6594                            {"agent": "analyst", "task": "Analyze findings"}
6595                        ]
6596                    }),
6597                }],
6598                stop_reason: StopReason::ToolUse,
6599                reasoning: None,
6600                usage: TokenUsage {
6601                    input_tokens: 50,
6602                    output_tokens: 20,
6603                    ..Default::default()
6604                },
6605                model: None,
6606            },
6607            // 2: Squad member "researcher" responds
6608            CompletionResponse {
6609                content: vec![ContentBlock::Text {
6610                    text: "Rust is fast and safe.".into(),
6611                }],
6612                stop_reason: StopReason::EndTurn,
6613                reasoning: None,
6614                usage: TokenUsage {
6615                    input_tokens: 10,
6616                    output_tokens: 8,
6617                    ..Default::default()
6618                },
6619                model: None,
6620            },
6621            // 3: Squad member "analyst" responds
6622            CompletionResponse {
6623                content: vec![ContentBlock::Text {
6624                    text: "Strengths: memory safety.".into(),
6625                }],
6626                stop_reason: StopReason::EndTurn,
6627                reasoning: None,
6628                usage: TokenUsage {
6629                    input_tokens: 12,
6630                    output_tokens: 10,
6631                    ..Default::default()
6632                },
6633                model: None,
6634            },
6635            // 4: Outer orchestrator synthesizes
6636            CompletionResponse {
6637                content: vec![ContentBlock::Text {
6638                    text: "Final: Rust is excellent.".into(),
6639                }],
6640                stop_reason: StopReason::EndTurn,
6641                reasoning: None,
6642                usage: TokenUsage {
6643                    input_tokens: 60,
6644                    output_tokens: 25,
6645                    ..Default::default()
6646                },
6647                model: None,
6648            },
6649        ]));
6650
6651        let mut orch = Orchestrator::builder(provider)
6652            .sub_agent("researcher", "Research specialist", "You research.")
6653            .sub_agent("analyst", "Analysis expert", "You analyze.")
6654            .sub_agent("coder", "Coding expert", "You code.")
6655            .build()
6656            .unwrap();
6657
6658        let output = orch.run("Analyze Rust deeply").await.unwrap();
6659        assert_eq!(output.result, "Final: Rust is excellent.");
6660    }
6661
6662    #[tokio::test]
6663    async fn form_squad_tokens_roll_up() {
6664        let provider = Arc::new(MockProvider::new(vec![
6665            // 1: Outer orchestrator calls form_squad
6666            CompletionResponse {
6667                content: vec![ContentBlock::ToolUse {
6668                    id: "call-1".into(),
6669                    name: "form_squad".into(),
6670                    input: json!({
6671                        "tasks": [
6672                            {"agent": "agent_a", "task": "Task A"},
6673                            {"agent": "agent_b", "task": "Task B"}
6674                        ]
6675                    }),
6676                }],
6677                stop_reason: StopReason::ToolUse,
6678                reasoning: None,
6679                usage: TokenUsage {
6680                    input_tokens: 50,
6681                    output_tokens: 20,
6682                    ..Default::default()
6683                },
6684                model: None,
6685            },
6686            // 2: Squad member agent_a responds
6687            CompletionResponse {
6688                content: vec![ContentBlock::Text {
6689                    text: "Done A.".into(),
6690                }],
6691                stop_reason: StopReason::EndTurn,
6692                reasoning: None,
6693                usage: TokenUsage {
6694                    input_tokens: 10,
6695                    output_tokens: 5,
6696                    ..Default::default()
6697                },
6698                model: None,
6699            },
6700            // 3: Squad member agent_b responds
6701            CompletionResponse {
6702                content: vec![ContentBlock::Text {
6703                    text: "Done B.".into(),
6704                }],
6705                stop_reason: StopReason::EndTurn,
6706                reasoning: None,
6707                usage: TokenUsage {
6708                    input_tokens: 12,
6709                    output_tokens: 6,
6710                    ..Default::default()
6711                },
6712                model: None,
6713            },
6714            // 4: Outer orchestrator synthesizes
6715            CompletionResponse {
6716                content: vec![ContentBlock::Text {
6717                    text: "All done.".into(),
6718                }],
6719                stop_reason: StopReason::EndTurn,
6720                reasoning: None,
6721                usage: TokenUsage {
6722                    input_tokens: 60,
6723                    output_tokens: 25,
6724                    ..Default::default()
6725                },
6726                model: None,
6727            },
6728        ]));
6729
6730        let mut orch = Orchestrator::builder(provider)
6731            .sub_agent("agent_a", "Agent A", "You are A.")
6732            .sub_agent("agent_b", "Agent B", "You are B.")
6733            .build()
6734            .unwrap();
6735
6736        let output = orch.run("Collaborate").await.unwrap();
6737        // Outer orchestrator: 50+60 in, 20+25 out
6738        // Squad members: agent_a 10 in + 5 out, agent_b 12 in + 6 out
6739        // No squad-leader overhead
6740        assert_eq!(
6741            output.tokens_used.input_tokens,
6742            50 + 60 + 10 + 12,
6743            "all token levels should roll up"
6744        );
6745        assert_eq!(
6746            output.tokens_used.output_tokens,
6747            20 + 25 + 5 + 6,
6748            "all token levels should roll up"
6749        );
6750    }
6751
6752    #[tokio::test]
6753    async fn form_squad_returns_error_for_unknown_agent() {
6754        let provider = Arc::new(MockProvider::new(vec![
6755            // 1: Outer orchestrator calls form_squad with unknown agent
6756            CompletionResponse {
6757                content: vec![ContentBlock::ToolUse {
6758                    id: "call-1".into(),
6759                    name: "form_squad".into(),
6760                    input: json!({
6761                        "tasks": [
6762                            {"agent": "researcher", "task": "Do research"},
6763                            {"agent": "nonexistent", "task": "Do stuff"}
6764                        ]
6765                    }),
6766                }],
6767                stop_reason: StopReason::ToolUse,
6768                reasoning: None,
6769                usage: TokenUsage::default(),
6770                model: None,
6771            },
6772            // 2: Orchestrator recovers
6773            CompletionResponse {
6774                content: vec![ContentBlock::Text {
6775                    text: "No such agent available.".into(),
6776                }],
6777                stop_reason: StopReason::EndTurn,
6778                reasoning: None,
6779                usage: TokenUsage::default(),
6780                model: None,
6781            },
6782        ]));
6783
6784        let mut orch = Orchestrator::builder(provider)
6785            .sub_agent("researcher", "Research", "prompt")
6786            .sub_agent("analyst", "Analysis", "prompt")
6787            .build()
6788            .unwrap();
6789
6790        let output = orch.run("delegate to unknown squad").await.unwrap();
6791        assert_eq!(output.result, "No such agent available.");
6792    }
6793
6794    #[tokio::test]
6795    async fn form_squad_requires_at_least_two_agents() {
6796        let provider = Arc::new(MockProvider::new(vec![
6797            // 1: Outer orchestrator tries to form squad with 1 task
6798            CompletionResponse {
6799                content: vec![ContentBlock::ToolUse {
6800                    id: "call-1".into(),
6801                    name: "form_squad".into(),
6802                    input: json!({
6803                        "tasks": [
6804                            {"agent": "researcher", "task": "Solo task"}
6805                        ]
6806                    }),
6807                }],
6808                stop_reason: StopReason::ToolUse,
6809                reasoning: None,
6810                usage: TokenUsage::default(),
6811                model: None,
6812            },
6813            // 2: Orchestrator recovers
6814            CompletionResponse {
6815                content: vec![ContentBlock::Text {
6816                    text: "Using delegate_task instead.".into(),
6817                }],
6818                stop_reason: StopReason::EndTurn,
6819                reasoning: None,
6820                usage: TokenUsage::default(),
6821                model: None,
6822            },
6823        ]));
6824
6825        let mut orch = Orchestrator::builder(provider)
6826            .sub_agent("researcher", "Research", "prompt")
6827            .sub_agent("analyst", "Analysis", "prompt")
6828            .build()
6829            .unwrap();
6830
6831        let output = orch.run("form solo squad").await.unwrap();
6832        assert_eq!(output.result, "Using delegate_task instead.");
6833    }
6834
6835    #[tokio::test]
6836    async fn form_squad_rejects_duplicate_agents() {
6837        let provider = Arc::new(MockProvider::new(vec![
6838            // 1: Outer orchestrator sends duplicate agent names
6839            CompletionResponse {
6840                content: vec![ContentBlock::ToolUse {
6841                    id: "call-1".into(),
6842                    name: "form_squad".into(),
6843                    input: json!({
6844                        "tasks": [
6845                            {"agent": "researcher", "task": "Task 1"},
6846                            {"agent": "researcher", "task": "Task 2"}
6847                        ]
6848                    }),
6849                }],
6850                stop_reason: StopReason::ToolUse,
6851                reasoning: None,
6852                usage: TokenUsage::default(),
6853                model: None,
6854            },
6855            // 2: Orchestrator recovers
6856            CompletionResponse {
6857                content: vec![ContentBlock::Text {
6858                    text: "Fixed duplicate issue.".into(),
6859                }],
6860                stop_reason: StopReason::EndTurn,
6861                reasoning: None,
6862                usage: TokenUsage::default(),
6863                model: None,
6864            },
6865        ]));
6866
6867        let mut orch = Orchestrator::builder(provider.clone())
6868            .sub_agent("researcher", "Research", "prompt")
6869            .sub_agent("analyst", "Analysis", "prompt")
6870            .build()
6871            .unwrap();
6872
6873        let output = orch.run("form squad with dupes").await.unwrap();
6874        assert_eq!(output.result, "Fixed duplicate issue.");
6875
6876        // Live finding (squad session): the model sent ["worker","worker"]
6877        // wanting PARALLEL same-agent tasks, and the bare "Duplicate agent
6878        // name" error left it retrying the same thing. The error must name
6879        // the available agents and point at delegate_task for same-agent
6880        // parallelism.
6881        let reqs = provider.captured_requests.lock().unwrap();
6882        let tool_result_text: String = reqs[1]
6883            .messages
6884            .iter()
6885            .flat_map(|m| m.content.iter())
6886            .filter_map(|b| match b {
6887                ContentBlock::ToolResult { content, .. } => Some(content.as_str()),
6888                _ => None,
6889            })
6890            .collect();
6891        assert!(
6892            tool_result_text.contains("researcher") && tool_result_text.contains("analyst"),
6893            "error must list the available agents: {tool_result_text}"
6894        );
6895        assert!(
6896            tool_result_text.contains("delegate_task"),
6897            "error must point at delegate_task for same-agent parallelism: {tool_result_text}"
6898        );
6899    }
6900
6901    #[tokio::test]
6902    async fn form_squad_private_blackboard() {
6903        use crate::agent::blackboard::InMemoryBlackboard;
6904
6905        let outer_bb = Arc::new(InMemoryBlackboard::new());
6906
6907        let provider = Arc::new(MockProvider::new(vec![
6908            // 1: Outer orchestrator calls form_squad
6909            CompletionResponse {
6910                content: vec![ContentBlock::ToolUse {
6911                    id: "call-1".into(),
6912                    name: "form_squad".into(),
6913                    input: json!({
6914                        "tasks": [
6915                            {"agent": "writer_a", "task": "Write something"},
6916                            {"agent": "writer_b", "task": "Write something else"}
6917                        ]
6918                    }),
6919                }],
6920                stop_reason: StopReason::ToolUse,
6921                reasoning: None,
6922                usage: TokenUsage::default(),
6923                model: None,
6924            },
6925            // 2: Squad member writer_a responds
6926            CompletionResponse {
6927                content: vec![ContentBlock::Text {
6928                    text: "Written to squad blackboard.".into(),
6929                }],
6930                stop_reason: StopReason::EndTurn,
6931                reasoning: None,
6932                usage: TokenUsage::default(),
6933                model: None,
6934            },
6935            // 3: Squad member writer_b responds
6936            CompletionResponse {
6937                content: vec![ContentBlock::Text {
6938                    text: "Also written.".into(),
6939                }],
6940                stop_reason: StopReason::EndTurn,
6941                reasoning: None,
6942                usage: TokenUsage::default(),
6943                model: None,
6944            },
6945            // 4: Outer orchestrator synthesizes
6946            CompletionResponse {
6947                content: vec![ContentBlock::Text {
6948                    text: "Done.".into(),
6949                }],
6950                stop_reason: StopReason::EndTurn,
6951                reasoning: None,
6952                usage: TokenUsage::default(),
6953                model: None,
6954            },
6955        ]));
6956
6957        let mut orch = Orchestrator::builder(provider)
6958            .sub_agent("writer_a", "Writer A", "You write.")
6959            .sub_agent("writer_b", "Writer B", "You write.")
6960            .blackboard(outer_bb.clone())
6961            .build()
6962            .unwrap();
6963
6964        orch.run("write to blackboard").await.unwrap();
6965
6966        // Squad result IS written to the outer blackboard under the "squad:" key.
6967        let squad_key = "squad:writer_a+writer_b";
6968        let val = outer_bb.read(squad_key).await.unwrap();
6969        assert!(
6970            val.is_some(),
6971            "outer blackboard should have squad result under '{squad_key}'"
6972        );
6973
6974        // The squad member's agent:writer_a key should NOT be in the outer blackboard
6975        // (it was written to the private blackboard inside the squad)
6976        let agent_key = "agent:writer_a";
6977        let val = outer_bb.read(agent_key).await.unwrap();
6978        assert!(
6979            val.is_none(),
6980            "outer blackboard should NOT have '{agent_key}' — that's on the private blackboard"
6981        );
6982    }
6983
6984    #[tokio::test]
6985    async fn form_squad_error_returns_tool_error_not_hard_error() {
6986        // One squad member's provider always fails → FormSquadTool returns
6987        // ToolOutput::error → outer orchestrator recovers gracefully.
6988        //
6989        // We use provider_override so agent_b gets a dedicated failing provider,
6990        // avoiding non-deterministic response ordering from a shared MockProvider.
6991
6992        let failing_provider = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
6993
6994        let provider = Arc::new(MockProvider::new(vec![
6995            // 1: Outer orchestrator calls form_squad
6996            CompletionResponse {
6997                content: vec![ContentBlock::ToolUse {
6998                    id: "call-1".into(),
6999                    name: "form_squad".into(),
7000                    input: json!({
7001                        "tasks": [
7002                            {"agent": "agent_a", "task": "Do A"},
7003                            {"agent": "agent_b", "task": "Do B"}
7004                        ]
7005                    }),
7006                }],
7007                stop_reason: StopReason::ToolUse,
7008                reasoning: None,
7009                usage: TokenUsage {
7010                    input_tokens: 50,
7011                    output_tokens: 20,
7012                    ..Default::default()
7013                },
7014                model: None,
7015            },
7016            // 2: Squad member agent_a responds (agent_b uses its own failing provider)
7017            CompletionResponse {
7018                content: vec![ContentBlock::Text {
7019                    text: "Done A.".into(),
7020                }],
7021                stop_reason: StopReason::EndTurn,
7022                reasoning: None,
7023                usage: TokenUsage {
7024                    input_tokens: 10,
7025                    output_tokens: 5,
7026                    ..Default::default()
7027                },
7028                model: None,
7029            },
7030            // 3: Outer orchestrator recovers from the squad error
7031            CompletionResponse {
7032                content: vec![ContentBlock::Text {
7033                    text: "Squad failed, falling back.".into(),
7034                }],
7035                stop_reason: StopReason::EndTurn,
7036                reasoning: None,
7037                usage: TokenUsage {
7038                    input_tokens: 60,
7039                    output_tokens: 25,
7040                    ..Default::default()
7041                },
7042                model: None,
7043            },
7044        ]));
7045
7046        let mut orch = Orchestrator::builder(provider)
7047            .sub_agent("agent_a", "Agent A", "You are A.")
7048            .sub_agent_full(SubAgentConfig {
7049                name: "agent_b".into(),
7050                description: "Agent B".into(),
7051                system_prompt: "You are B.".into(),
7052                tools: vec![],
7053                context_strategy: None,
7054                summarize_threshold: None,
7055                tool_timeout: None,
7056                max_tool_output_bytes: None,
7057                max_turns: None,
7058                max_tokens: None,
7059                response_schema: None,
7060                run_timeout: None,
7061                guardrails: vec![],
7062                provider: Some(failing_provider),
7063                reasoning_effort: None,
7064                enable_reflection: None,
7065                tool_output_compression_threshold: None,
7066                max_tools_per_turn: None,
7067                tool_profile: None,
7068                max_identical_tool_calls: None,
7069                max_fuzzy_identical_tool_calls: None,
7070                max_tool_calls_per_turn: None,
7071                session_prune_config: None,
7072                enable_recursive_summarization: None,
7073                reflection_threshold: None,
7074                consolidate_on_exit: None,
7075                workspace: None,
7076                max_total_tokens: None,
7077                audit_trail: None,
7078                audit_user_id: None,
7079                audit_tenant_id: None,
7080                audit_delegation_chain: Vec::new(),
7081                context: Default::default(),
7082            })
7083            .build()
7084            .unwrap();
7085
7086        let output = orch.run("complex task").await.unwrap();
7087        assert_eq!(output.result, "Squad failed, falling back.");
7088        // Partial tokens from the successful squad member should be accumulated
7089        assert!(
7090            output.tokens_used.input_tokens > 50 + 60,
7091            "should include partial squad tokens: {}",
7092            output.tokens_used.input_tokens
7093        );
7094    }
7095
7096    #[tokio::test]
7097    async fn orchestrator_registers_both_tools() {
7098        use crate::llm::types::CompletionRequest;
7099
7100        struct ToolCapturingProvider {
7101            responses: Mutex<Vec<CompletionResponse>>,
7102            tool_names_seen: Mutex<Vec<Vec<String>>>,
7103        }
7104
7105        impl LlmProvider for ToolCapturingProvider {
7106            async fn complete(
7107                &self,
7108                request: CompletionRequest,
7109            ) -> Result<CompletionResponse, Error> {
7110                let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
7111                self.tool_names_seen.lock().expect("lock").push(names);
7112                let mut responses = self.responses.lock().expect("lock");
7113                if responses.is_empty() {
7114                    return Err(Error::Agent("no more responses".into()));
7115                }
7116                Ok(responses.remove(0))
7117            }
7118        }
7119
7120        let provider = Arc::new(ToolCapturingProvider {
7121            responses: Mutex::new(vec![CompletionResponse {
7122                content: vec![ContentBlock::Text {
7123                    text: "Direct answer.".into(),
7124                }],
7125                stop_reason: StopReason::EndTurn,
7126                reasoning: None,
7127                usage: TokenUsage::default(),
7128                model: None,
7129            }]),
7130            tool_names_seen: Mutex::new(vec![]),
7131        });
7132
7133        // >= 2 agents → squads auto-enabled
7134        let mut orch = Orchestrator::builder(provider.clone())
7135            .sub_agent("researcher", "Research", "prompt")
7136            .sub_agent("analyst", "Analysis", "prompt")
7137            .build()
7138            .unwrap();
7139
7140        orch.run("test").await.unwrap();
7141
7142        let tool_names = provider.tool_names_seen.lock().unwrap();
7143        assert!(
7144            tool_names[0].contains(&"delegate_task".to_string()),
7145            "should have delegate_task: {:?}",
7146            tool_names[0]
7147        );
7148        assert!(
7149            tool_names[0].contains(&"form_squad".to_string()),
7150            "should have form_squad: {:?}",
7151            tool_names[0]
7152        );
7153    }
7154
7155    #[test]
7156    fn orchestrator_single_agent_no_squads() {
7157        let provider = Arc::new(MockProvider::new(vec![]));
7158
7159        // Only 1 agent → squads auto-disabled
7160        let result = Orchestrator::builder(provider)
7161            .sub_agent("researcher", "Research", "prompt")
7162            .build();
7163
7164        assert!(result.is_ok());
7165    }
7166
7167    #[tokio::test]
7168    async fn orchestrator_squads_disabled_explicitly() {
7169        use crate::llm::types::CompletionRequest;
7170
7171        struct ToolCapturingProvider {
7172            responses: Mutex<Vec<CompletionResponse>>,
7173            tool_names_seen: Mutex<Vec<Vec<String>>>,
7174        }
7175
7176        impl LlmProvider for ToolCapturingProvider {
7177            async fn complete(
7178                &self,
7179                request: CompletionRequest,
7180            ) -> Result<CompletionResponse, Error> {
7181                let names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect();
7182                self.tool_names_seen.lock().expect("lock").push(names);
7183                let mut responses = self.responses.lock().expect("lock");
7184                if responses.is_empty() {
7185                    return Err(Error::Agent("no more responses".into()));
7186                }
7187                Ok(responses.remove(0))
7188            }
7189        }
7190
7191        let provider = Arc::new(ToolCapturingProvider {
7192            responses: Mutex::new(vec![CompletionResponse {
7193                content: vec![ContentBlock::Text {
7194                    text: "Direct answer.".into(),
7195                }],
7196                stop_reason: StopReason::EndTurn,
7197                reasoning: None,
7198                usage: TokenUsage::default(),
7199                model: None,
7200            }]),
7201            tool_names_seen: Mutex::new(vec![]),
7202        });
7203
7204        // 2 agents BUT squads explicitly disabled
7205        let mut orch = Orchestrator::builder(provider.clone())
7206            .sub_agent("researcher", "Research", "prompt")
7207            .sub_agent("analyst", "Analysis", "prompt")
7208            .enable_squads(false)
7209            .build()
7210            .unwrap();
7211
7212        orch.run("test").await.unwrap();
7213
7214        let tool_names = provider.tool_names_seen.lock().unwrap();
7215        assert!(
7216            tool_names[0].contains(&"delegate_task".to_string()),
7217            "should have delegate_task: {:?}",
7218            tool_names[0]
7219        );
7220        assert!(
7221            !tool_names[0].contains(&"form_squad".to_string()),
7222            "should NOT have form_squad when disabled: {:?}",
7223            tool_names[0]
7224        );
7225    }
7226
7227    #[test]
7228    fn system_prompt_mentions_both_tools_when_squads_enabled() {
7229        let tools = vec!["web_search".to_string()];
7230        let agents: Vec<(&str, &str, &[String])> = vec![
7231            ("researcher", "Research specialist", tools.as_slice()),
7232            ("analyst", "Analysis expert", &[]),
7233        ];
7234
7235        let prompt = build_system_prompt(&agents, true, DispatchMode::Parallel);
7236        assert!(
7237            prompt.contains("delegate_task"),
7238            "prompt should mention delegate_task: {prompt}"
7239        );
7240        assert!(
7241            prompt.contains("form_squad"),
7242            "prompt should mention form_squad: {prompt}"
7243        );
7244        assert!(
7245            prompt.contains("two delegation tools"),
7246            "prompt should explain both tools: {prompt}"
7247        );
7248        // Squads-enabled prompt should distinguish isolation vs collaboration
7249        assert!(
7250            prompt.contains("isolation"),
7251            "prompt should mention isolation for delegate_task: {prompt}"
7252        );
7253        assert!(
7254            prompt.contains("blackboard"),
7255            "prompt should mention shared blackboard for form_squad: {prompt}"
7256        );
7257    }
7258
7259    #[test]
7260    fn system_prompt_only_delegate_when_squads_disabled() {
7261        let agents: Vec<(&str, &str, &[String])> = vec![("researcher", "Research specialist", &[])];
7262
7263        let prompt = build_system_prompt(&agents, false, DispatchMode::Parallel);
7264        assert!(
7265            prompt.contains("delegate_task"),
7266            "prompt should mention delegate_task: {prompt}"
7267        );
7268        assert!(
7269            !prompt.contains("form_squad"),
7270            "prompt should NOT mention form_squad: {prompt}"
7271        );
7272        // Still has decision framework
7273        assert!(
7274            prompt.contains("Decision Process"),
7275            "prompt should contain Decision Process even without squads: {prompt}"
7276        );
7277        assert!(
7278            prompt.contains("Effort Scaling"),
7279            "prompt should contain Effort Scaling even without squads: {prompt}"
7280        );
7281    }
7282
7283    #[tokio::test]
7284    async fn delegate_forwards_on_event_to_sub_agents() {
7285        // Verify that on_event receives events from sub-agents (not just orchestrator)
7286        let provider = Arc::new(MockProvider::new(vec![
7287            // 1: Orchestrator decides to delegate
7288            CompletionResponse {
7289                content: vec![ContentBlock::ToolUse {
7290                    id: "call-1".into(),
7291                    name: "delegate_task".into(),
7292                    input: json!({
7293                        "tasks": [
7294                            {"agent": "worker", "task": "do work"}
7295                        ]
7296                    }),
7297                }],
7298                stop_reason: StopReason::ToolUse,
7299                reasoning: None,
7300                usage: TokenUsage::default(),
7301                model: None,
7302            },
7303            // 2: Sub-agent "worker" response
7304            CompletionResponse {
7305                content: vec![ContentBlock::Text {
7306                    text: "done".into(),
7307                }],
7308                stop_reason: StopReason::EndTurn,
7309                reasoning: None,
7310                usage: TokenUsage::default(),
7311                model: None,
7312            },
7313            // 3: Orchestrator synthesis
7314            CompletionResponse {
7315                content: vec![ContentBlock::Text {
7316                    text: "All done.".into(),
7317                }],
7318                stop_reason: StopReason::EndTurn,
7319                reasoning: None,
7320                usage: TokenUsage::default(),
7321                model: None,
7322            },
7323        ]));
7324
7325        let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
7326        let events_clone = events.clone();
7327        let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
7328            events_clone.lock().expect("test lock").push(event);
7329        });
7330
7331        let mut orch = Orchestrator::builder(provider)
7332            .sub_agent("worker", "Worker agent", "You do work.")
7333            .on_event(on_event)
7334            .build()
7335            .unwrap();
7336
7337        let _output = orch.run("delegate some work").await.unwrap();
7338
7339        let events = events.lock().expect("test lock");
7340
7341        // Should have events from both "orchestrator" and "worker"
7342        let orchestrator_events: Vec<_> = events
7343            .iter()
7344            .filter(|e| match e {
7345                AgentEvent::RunStarted { agent, .. }
7346                | AgentEvent::TurnStarted { agent, .. }
7347                | AgentEvent::LlmResponse { agent, .. }
7348                | AgentEvent::RunCompleted { agent, .. } => agent == "orchestrator",
7349                _ => false,
7350            })
7351            .collect();
7352        let worker_events: Vec<_> = events
7353            .iter()
7354            .filter(|e| match e {
7355                AgentEvent::RunStarted { agent, .. }
7356                | AgentEvent::TurnStarted { agent, .. }
7357                | AgentEvent::LlmResponse { agent, .. }
7358                | AgentEvent::RunCompleted { agent, .. } => agent == "worker",
7359                _ => false,
7360            })
7361            .collect();
7362
7363        assert!(
7364            !orchestrator_events.is_empty(),
7365            "should have orchestrator events"
7366        );
7367        assert!(
7368            !worker_events.is_empty(),
7369            "should have sub-agent worker events (forwarded via on_event)"
7370        );
7371
7372        // Verify worker had a RunStarted event
7373        let worker_run_started = events
7374            .iter()
7375            .any(|e| matches!(e, AgentEvent::RunStarted { agent, .. } if agent == "worker"));
7376        assert!(
7377            worker_run_started,
7378            "sub-agent should emit RunStarted via forwarded on_event"
7379        );
7380    }
7381
7382    /// Complex end-to-end audit trail test.
7383    ///
7384    /// Scenario:
7385    ///   1. Orchestrator delegates to 2 sub-agents: "researcher" (has web_search tool)
7386    ///      and "coder" (has read_file tool)
7387    ///   2. Each sub-agent calls its tool, produces output
7388    ///   3. Orchestrator synthesizes results
7389    ///
7390    /// Verifies:
7391    ///   - Complete event stream ordering
7392    ///   - Agent names on every event
7393    ///   - LlmResponse contains text, latency_ms > 0, model name
7394    ///   - ToolCallStarted contains input JSON
7395    ///   - ToolCallCompleted contains output content
7396    ///   - Sub-agent events are forwarded (not just orchestrator events)
7397    ///   - SubAgentsDispatched + SubAgentCompleted bracket sub-agent work
7398    ///   - Token usage rolls up correctly
7399    ///   - Truncation works for oversized tool output
7400    #[tokio::test]
7401    async fn full_audit_trail_end_to_end() {
7402        // Build a long tool output to test truncation
7403        let long_output = "x".repeat(70_000); // > EVENT_MAX_PAYLOAD_BYTES (65536)
7404
7405        let provider = Arc::new(MockProvider::new(vec![
7406            // 1: Orchestrator decides to delegate to both agents (with reasoning text)
7407            CompletionResponse {
7408                content: vec![
7409                    ContentBlock::Text {
7410                        text: "I'll delegate to the researcher and coder.".into(),
7411                    },
7412                    ContentBlock::ToolUse {
7413                        id: "orch-call-1".into(),
7414                        name: "delegate_task".into(),
7415                        input: json!({
7416                            "tasks": [
7417                                {"agent": "researcher", "task": "Search for Rust concurrency patterns"},
7418                                {"agent": "coder", "task": "Read the main.rs file"}
7419                            ]
7420                        }),
7421                    },
7422                ],
7423                stop_reason: StopReason::ToolUse,
7424                reasoning: None,
7425                usage: TokenUsage {
7426                    input_tokens: 100,
7427                    output_tokens: 40,
7428                    ..Default::default()
7429                },
7430                model: None,
7431            },
7432            // 2: Sub-agent "researcher" LLM response: calls web_search tool
7433            CompletionResponse {
7434                content: vec![
7435                    ContentBlock::Text {
7436                        text: "Let me search for Rust concurrency info.".into(),
7437                    },
7438                    ContentBlock::ToolUse {
7439                        id: "res-call-1".into(),
7440                        name: "web_search".into(),
7441                        input: json!({"query": "rust async concurrency"}),
7442                    },
7443                ],
7444                stop_reason: StopReason::ToolUse,
7445                reasoning: None,
7446                usage: TokenUsage {
7447                    input_tokens: 20,
7448                    output_tokens: 10,
7449                    ..Default::default()
7450                },
7451                model: None,
7452            },
7453            // 3: Sub-agent "researcher" final response after tool result
7454            CompletionResponse {
7455                content: vec![ContentBlock::Text {
7456                    text: "Rust uses async/await with tokio for concurrency.".into(),
7457                }],
7458                stop_reason: StopReason::EndTurn,
7459                reasoning: None,
7460                usage: TokenUsage {
7461                    input_tokens: 30,
7462                    output_tokens: 15,
7463                    ..Default::default()
7464                },
7465                model: None,
7466            },
7467            // 4: Sub-agent "coder" LLM response: calls read_file tool
7468            CompletionResponse {
7469                content: vec![
7470                    ContentBlock::Text {
7471                        text: "I'll read the main.rs file.".into(),
7472                    },
7473                    ContentBlock::ToolUse {
7474                        id: "cod-call-1".into(),
7475                        name: "read_file".into(),
7476                        input: json!({"path": "/src/main.rs"}),
7477                    },
7478                ],
7479                stop_reason: StopReason::ToolUse,
7480                reasoning: None,
7481                usage: TokenUsage {
7482                    input_tokens: 15,
7483                    output_tokens: 8,
7484                    ..Default::default()
7485                },
7486                model: None,
7487            },
7488            // 5: Sub-agent "coder" final response after tool result
7489            CompletionResponse {
7490                content: vec![ContentBlock::Text {
7491                    text: "The main.rs contains the entry point.".into(),
7492                }],
7493                stop_reason: StopReason::EndTurn,
7494                reasoning: None,
7495                usage: TokenUsage {
7496                    input_tokens: 25,
7497                    output_tokens: 12,
7498                    ..Default::default()
7499                },
7500                model: None,
7501            },
7502            // 6: Orchestrator synthesis
7503            CompletionResponse {
7504                content: vec![ContentBlock::Text {
7505                    text: "Combined analysis: Rust async is great for concurrency.".into(),
7506                }],
7507                stop_reason: StopReason::EndTurn,
7508                reasoning: None,
7509                usage: TokenUsage {
7510                    input_tokens: 200,
7511                    output_tokens: 50,
7512                    ..Default::default()
7513                },
7514                model: None,
7515            },
7516        ]));
7517
7518        let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
7519        let events_clone = events.clone();
7520        let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
7521            events_clone.lock().expect("test lock").push(event);
7522        });
7523
7524        // Both agents get both tools so the test is resilient to JoinSet ordering.
7525        // (Sub-agents share a MockProvider and run concurrently — response order is non-deterministic.)
7526        let long_output_clone = long_output.clone();
7527        let shared_tools: Vec<Arc<dyn Tool>> = vec![
7528            Arc::new(MockTool::new("web_search", &long_output_clone)),
7529            Arc::new(MockTool::new(
7530                "read_file",
7531                "fn main() { println!(\"hello\"); }",
7532            )),
7533        ];
7534        let mut orch = Orchestrator::builder(provider)
7535            .sub_agent_with_tools(
7536                "researcher",
7537                "Research specialist",
7538                "You research topics.",
7539                shared_tools.clone(),
7540            )
7541            .sub_agent_with_tools(
7542                "coder",
7543                "Code expert",
7544                "You read and analyze code.",
7545                shared_tools.clone(),
7546            )
7547            .on_event(on_event)
7548            .build()
7549            .unwrap();
7550
7551        let output = orch
7552            .run("Analyze Rust concurrency and the main.rs file")
7553            .await
7554            .unwrap();
7555
7556        // === Verify final output ===
7557        assert_eq!(
7558            output.result,
7559            "Combined analysis: Rust async is great for concurrency."
7560        );
7561        assert_eq!(output.tool_calls_made, 1); // orchestrator made 1 delegate_task call
7562
7563        // === Collect and categorize events ===
7564        let events = events.lock().expect("test lock");
7565
7566        // Helper to extract agent name from any event
7567        fn agent_of(e: &AgentEvent) -> &str {
7568            match e {
7569                AgentEvent::RunStarted { agent, .. }
7570                | AgentEvent::TurnStarted { agent, .. }
7571                | AgentEvent::LlmResponse { agent, .. }
7572                | AgentEvent::Reasoning { agent, .. }
7573                | AgentEvent::ToolCallStarted { agent, .. }
7574                | AgentEvent::ToolCallCompleted { agent, .. }
7575                | AgentEvent::RunCompleted { agent, .. }
7576                | AgentEvent::RunFailed { agent, .. }
7577                | AgentEvent::SubAgentsDispatched { agent, .. }
7578                | AgentEvent::SubAgentCompleted { agent, .. }
7579                | AgentEvent::ApprovalRequested { agent, .. }
7580                | AgentEvent::ApprovalDecision { agent, .. }
7581                | AgentEvent::ContextSummarized { agent, .. }
7582                | AgentEvent::GuardrailDenied { agent, .. }
7583                | AgentEvent::GuardrailWarned { agent, .. }
7584                | AgentEvent::RequestRouted { agent, .. }
7585                | AgentEvent::GateFired { agent, .. }
7586                | AgentEvent::RetryAttempt { agent, .. }
7587                | AgentEvent::DoomLoopDetected { agent, .. }
7588                | AgentEvent::FuzzyDoomLoopDetected { agent, .. }
7589                | AgentEvent::AutoCompactionTriggered { agent, .. }
7590                | AgentEvent::SessionPruned { agent, .. }
7591                | AgentEvent::ModelEscalated { agent, .. }
7592                | AgentEvent::BudgetExceeded { agent, .. }
7593                | AgentEvent::AgentSpawned { agent, .. }
7594                | AgentEvent::KillSwitchActivated { agent, .. }
7595                | AgentEvent::ToolNameRepaired { agent, .. } => agent,
7596                AgentEvent::SensorEventProcessed { sensor_name, .. } => sensor_name,
7597                AgentEvent::StoryUpdated { story_id, .. } => story_id,
7598                AgentEvent::TaskRouted { decision, .. } => decision,
7599                AgentEvent::WorkflowNodeStarted { node, .. }
7600                | AgentEvent::WorkflowNodeCompleted { node, .. }
7601                | AgentEvent::WorkflowNodeFailed { node, .. } => node,
7602            }
7603        }
7604
7605        // Print event stream for debugging
7606        let event_summary: Vec<String> = events
7607            .iter()
7608            .enumerate()
7609            .map(|(i, e)| format!("{i}: [{:>12}] {:?}", agent_of(e), std::mem::discriminant(e)))
7610            .collect();
7611
7612        // === 1. Verify we have events from all 3 agents ===
7613        let agents_seen: std::collections::HashSet<&str> = events.iter().map(agent_of).collect();
7614        assert!(
7615            agents_seen.contains("orchestrator"),
7616            "missing orchestrator events.\nEvent stream:\n{}",
7617            event_summary.join("\n")
7618        );
7619        assert!(
7620            agents_seen.contains("researcher"),
7621            "missing researcher events (should be forwarded).\nEvent stream:\n{}",
7622            event_summary.join("\n")
7623        );
7624        assert!(
7625            agents_seen.contains("coder"),
7626            "missing coder events (should be forwarded).\nEvent stream:\n{}",
7627            event_summary.join("\n")
7628        );
7629
7630        // === 2. Verify orchestrator event sequence ===
7631        let orch_events: Vec<&AgentEvent> = events
7632            .iter()
7633            .filter(|e| agent_of(e) == "orchestrator")
7634            .collect();
7635
7636        // First orchestrator event: RunStarted
7637        assert!(
7638            matches!(orch_events[0], AgentEvent::RunStarted { task, .. } if task.contains("Analyze Rust")),
7639            "first orch event should be RunStarted, got: {:?}",
7640            orch_events[0]
7641        );
7642        // Last orchestrator event: RunCompleted
7643        assert!(
7644            matches!(orch_events.last().unwrap(), AgentEvent::RunCompleted { .. }),
7645            "last orch event should be RunCompleted, got: {:?}",
7646            orch_events.last().unwrap()
7647        );
7648
7649        // === 3. Verify LlmResponse events have text, latency, and model ===
7650        let llm_responses: Vec<&AgentEvent> = events
7651            .iter()
7652            .filter(|e| matches!(e, AgentEvent::LlmResponse { .. }))
7653            .collect();
7654        assert!(
7655            llm_responses.len() >= 3,
7656            "expected >= 3 LlmResponse events (1 orch + at least 1 per sub-agent), got {}.\nEvents:\n{}",
7657            llm_responses.len(),
7658            event_summary.join("\n")
7659        );
7660
7661        for llm_event in &llm_responses {
7662            match llm_event {
7663                AgentEvent::LlmResponse {
7664                    agent, text, model, ..
7665                } => {
7666                    // model_name should always be present (MockProvider returns "mock-model-v1")
7667                    assert_eq!(
7668                        model.as_deref(),
7669                        Some("mock-model-v1"),
7670                        "LlmResponse for '{agent}' should have model name"
7671                    );
7672                    // text should be non-empty (all our mock responses produce content)
7673                    assert!(
7674                        !text.is_empty(),
7675                        "LlmResponse for '{agent}' should have non-empty text"
7676                    );
7677                }
7678                _ => unreachable!(),
7679            }
7680        }
7681
7682        // === 4. Verify ToolCallStarted events have input ===
7683        let tool_started: Vec<&AgentEvent> = events
7684            .iter()
7685            .filter(|e| matches!(e, AgentEvent::ToolCallStarted { .. }))
7686            .collect();
7687        // Expect 3 tool calls: 1 delegate_task (orch) + 1 web_search (researcher) + 1 read_file (coder)
7688        assert!(
7689            tool_started.len() >= 3,
7690            "expected >= 3 ToolCallStarted events, got {}.\nEvents:\n{}",
7691            tool_started.len(),
7692            event_summary.join("\n")
7693        );
7694
7695        // Find the web_search ToolCallStarted and verify input contains the query.
7696        // Note: agent name is NOT asserted because JoinSet ordering is non-deterministic.
7697        let web_search_started = tool_started.iter().find(|e| {
7698            matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "web_search")
7699        });
7700        assert!(
7701            web_search_started.is_some(),
7702            "should have a web_search ToolCallStarted"
7703        );
7704        match web_search_started.unwrap() {
7705            AgentEvent::ToolCallStarted { input, .. } => {
7706                assert!(
7707                    input.contains("rust async concurrency"),
7708                    "web_search input should contain query, got: {input}"
7709                );
7710            }
7711            _ => unreachable!(),
7712        }
7713
7714        // Find the read_file ToolCallStarted and verify input
7715        let read_file_started = tool_started.iter().find(|e| {
7716            matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "read_file")
7717        });
7718        assert!(
7719            read_file_started.is_some(),
7720            "should have a read_file ToolCallStarted"
7721        );
7722        match read_file_started.unwrap() {
7723            AgentEvent::ToolCallStarted { input, .. } => {
7724                assert!(
7725                    input.contains("/src/main.rs"),
7726                    "read_file input should contain path, got: {input}"
7727                );
7728            }
7729            _ => unreachable!(),
7730        }
7731
7732        // Find the delegate_task ToolCallStarted and verify input
7733        let delegate_started = tool_started.iter().find(|e| {
7734            matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "delegate_task")
7735        });
7736        assert!(
7737            delegate_started.is_some(),
7738            "should have a delegate_task ToolCallStarted"
7739        );
7740        match delegate_started.unwrap() {
7741            AgentEvent::ToolCallStarted { agent, input, .. } => {
7742                assert_eq!(agent, "orchestrator");
7743                assert!(
7744                    input.contains("researcher"),
7745                    "delegate_task input should contain agent names, got: {input}"
7746                );
7747            }
7748            _ => unreachable!(),
7749        }
7750
7751        // === 5. Verify ToolCallCompleted events have output ===
7752        let tool_completed: Vec<&AgentEvent> = events
7753            .iter()
7754            .filter(|e| matches!(e, AgentEvent::ToolCallCompleted { .. }))
7755            .collect();
7756        assert!(
7757            tool_completed.len() >= 3,
7758            "expected >= 3 ToolCallCompleted events, got {}",
7759            tool_completed.len()
7760        );
7761
7762        // Find the web_search completion and verify truncation
7763        let web_search_completed = tool_completed.iter().find(|e| {
7764            matches!(e, AgentEvent::ToolCallCompleted { tool_name, .. } if tool_name == "web_search")
7765        });
7766        assert!(
7767            web_search_completed.is_some(),
7768            "should have a web_search ToolCallCompleted"
7769        );
7770        match web_search_completed.unwrap() {
7771            AgentEvent::ToolCallCompleted {
7772                output, is_error, ..
7773            } => {
7774                assert!(!is_error);
7775                // The web_search tool returns 70_000 bytes, exceeds EVENT_MAX_PAYLOAD_BYTES (65536)
7776                assert!(
7777                    output.contains("[truncated:"),
7778                    "web_search output (70000 bytes) should be truncated in event, got {} bytes: {}",
7779                    output.len(),
7780                    &output[..output.len().min(100)]
7781                );
7782            }
7783            _ => unreachable!(),
7784        }
7785
7786        // Find the read_file completion — should NOT be truncated (short output)
7787        let read_file_completed = tool_completed.iter().find(|e| {
7788            matches!(e, AgentEvent::ToolCallCompleted { tool_name, .. } if tool_name == "read_file")
7789        });
7790        assert!(
7791            read_file_completed.is_some(),
7792            "should have a read_file ToolCallCompleted"
7793        );
7794        match read_file_completed.unwrap() {
7795            AgentEvent::ToolCallCompleted {
7796                output, is_error, ..
7797            } => {
7798                assert!(!is_error);
7799                assert!(
7800                    output.contains("fn main()"),
7801                    "read_file output should contain file content, got: {output}"
7802                );
7803                assert!(
7804                    !output.contains("[truncated:"),
7805                    "read_file output should NOT be truncated"
7806                );
7807            }
7808            _ => unreachable!(),
7809        }
7810
7811        // === 6. Verify SubAgentsDispatched and SubAgentCompleted events ===
7812        let dispatched: Vec<&AgentEvent> = events
7813            .iter()
7814            .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
7815            .collect();
7816        assert_eq!(dispatched.len(), 1, "expected 1 SubAgentsDispatched event");
7817        match dispatched[0] {
7818            AgentEvent::SubAgentsDispatched { agents, .. } => {
7819                assert!(
7820                    agents.contains(&"researcher".to_string()),
7821                    "dispatched agents should include researcher"
7822                );
7823                assert!(
7824                    agents.contains(&"coder".to_string()),
7825                    "dispatched agents should include coder"
7826                );
7827            }
7828            _ => unreachable!(),
7829        }
7830
7831        let completed: Vec<&AgentEvent> = events
7832            .iter()
7833            .filter(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }))
7834            .collect();
7835        assert_eq!(
7836            completed.len(),
7837            2,
7838            "expected 2 SubAgentCompleted events (one per sub-agent)"
7839        );
7840        for c in &completed {
7841            match c {
7842                AgentEvent::SubAgentCompleted { success, agent, .. } => {
7843                    assert!(success, "sub-agent '{agent}' should succeed");
7844                }
7845                _ => unreachable!(),
7846            }
7847        }
7848
7849        // === 7. Verify event ordering: RunStarted is always first for each agent ===
7850        for agent_name in &["orchestrator", "researcher", "coder"] {
7851            let agent_events: Vec<&AgentEvent> = events
7852                .iter()
7853                .filter(|e| agent_of(e) == *agent_name)
7854                .collect();
7855            if !agent_events.is_empty() {
7856                assert!(
7857                    matches!(agent_events[0], AgentEvent::RunStarted { .. }),
7858                    "first event for '{agent_name}' should be RunStarted, got: {:?}",
7859                    agent_events[0]
7860                );
7861            }
7862        }
7863
7864        // === 8. Verify token roll-up ===
7865        // Orchestrator: 100+200 input, 40+50 output
7866        // Researcher: 20+30 input, 10+15 output
7867        // Coder: 15+25 input, 8+12 output
7868        // Total: 390 input, 135 output
7869        assert_eq!(
7870            output.tokens_used.input_tokens,
7871            100 + 200 + 20 + 30 + 15 + 25,
7872            "total input tokens should include orchestrator + sub-agents"
7873        );
7874        assert_eq!(
7875            output.tokens_used.output_tokens,
7876            40 + 50 + 10 + 15 + 8 + 12,
7877            "total output tokens should include orchestrator + sub-agents"
7878        );
7879
7880        // === 9. Verify a sub-agent LlmResponse contains research text ===
7881        // (Agent name not asserted due to JoinSet ordering non-determinism)
7882        let sub_agent_llm = llm_responses.iter().find(|e| {
7883            matches!(e, AgentEvent::LlmResponse { text, .. }
7884                if text.contains("async/await"))
7885        });
7886        assert!(
7887            sub_agent_llm.is_some(),
7888            "should have a sub-agent LlmResponse with text about async/await"
7889        );
7890
7891        // === 10. Verify total event count is reasonable ===
7892        // Minimum: 3 agents × (RunStarted + TurnStarted + LlmResponse) + tool events + completion events
7893        // Orchestrator: RunStarted, TurnStarted, LlmResponse, ToolCallStarted(delegate), ToolCallCompleted(delegate),
7894        //               TurnStarted, LlmResponse, RunCompleted = ~8
7895        // Researcher:   RunStarted, TurnStarted, LlmResponse, ToolCallStarted, ToolCallCompleted,
7896        //               TurnStarted, LlmResponse, RunCompleted = ~8
7897        // Coder:        same = ~8
7898        // + SubAgentsDispatched + 2× SubAgentCompleted = 3
7899        // Total ~27 events
7900        assert!(
7901            events.len() >= 20,
7902            "expected at least 20 events for full audit trail, got {}.\nEvents:\n{}",
7903            events.len(),
7904            event_summary.join("\n")
7905        );
7906    }
7907
7908    #[tokio::test]
7909    async fn sub_agent_run_timeout_fires_when_configured() {
7910        // Provider that responds immediately for the orchestrator (delegate call),
7911        // then hangs forever for the sub-agent (simulating a slow LLM),
7912        // then synthesizes the timeout error result.
7913        let provider = Arc::new(MockProvider::new(vec![
7914            // 1: Orchestrator decides to delegate to "slow-agent"
7915            CompletionResponse {
7916                content: vec![ContentBlock::ToolUse {
7917                    id: "call-1".into(),
7918                    name: "delegate_task".into(),
7919                    input: json!({
7920                        "tasks": [{"agent": "slow-agent", "task": "do something"}]
7921                    }),
7922                }],
7923                stop_reason: StopReason::ToolUse,
7924                reasoning: None,
7925                usage: TokenUsage::default(),
7926                model: None,
7927            },
7928            // 2: Sub-agent "slow-agent" — hangs forever (timeout will fire)
7929            // This response won't be consumed because the provider will run out
7930            // But we need a 3rd response for the orchestrator's synthesis turn.
7931        ]));
7932
7933        // Use a separate SlowProvider for the sub-agent via provider override
7934        struct SlowProvider;
7935        impl LlmProvider for SlowProvider {
7936            async fn complete(
7937                &self,
7938                _request: CompletionRequest,
7939            ) -> Result<CompletionResponse, Error> {
7940                tokio::time::sleep(Duration::from_secs(3600)).await;
7941                unreachable!()
7942            }
7943        }
7944        let slow_provider = Arc::new(BoxedProvider::new(SlowProvider));
7945
7946        let mut orch = Orchestrator::builder(provider)
7947            .sub_agent_full(SubAgentConfig {
7948                name: "slow-agent".into(),
7949                description: "A slow agent".into(),
7950                system_prompt: "sys".into(),
7951                tools: vec![],
7952                context_strategy: None,
7953                summarize_threshold: None,
7954                tool_timeout: None,
7955                max_tool_output_bytes: None,
7956                max_turns: None,
7957                max_tokens: None,
7958                response_schema: None,
7959                run_timeout: Some(Duration::from_millis(100)),
7960                guardrails: vec![],
7961                provider: Some(slow_provider),
7962                reasoning_effort: None,
7963                enable_reflection: None,
7964                tool_output_compression_threshold: None,
7965                max_tools_per_turn: None,
7966                tool_profile: None,
7967                max_identical_tool_calls: None,
7968                max_fuzzy_identical_tool_calls: None,
7969                max_tool_calls_per_turn: None,
7970                session_prune_config: None,
7971                enable_recursive_summarization: None,
7972                reflection_threshold: None,
7973                consolidate_on_exit: None,
7974                workspace: None,
7975                max_total_tokens: None,
7976                audit_trail: None,
7977                audit_user_id: None,
7978                audit_tenant_id: None,
7979                audit_delegation_chain: Vec::new(),
7980                context: Default::default(),
7981            })
7982            .build()
7983            .unwrap();
7984
7985        // The orchestrator will delegate, the sub-agent will timeout,
7986        // and the error propagates back as a delegate_task tool result.
7987        // The orchestrator then tries to synthesize but has no more responses.
7988        let result = orch.run("go").await;
7989        // The orchestrator either returns an error (no more mock responses for synthesis)
7990        // or the result mentions timeout. Either way, the sub-agent's run_timeout fired.
7991        match result {
7992            Ok(output) => {
7993                // If somehow we got a result, it should mention timeout
7994                assert!(
7995                    output.result.contains("timeout") || output.result.contains("Timeout"),
7996                    "expected timeout in result, got: {}",
7997                    output.result
7998                );
7999            }
8000            Err(e) => {
8001                // The orchestrator ran out of mock responses after the sub-agent timed out,
8002                // which is fine — it confirms the sub-agent timeout was wired.
8003                let msg = e.to_string();
8004                assert!(
8005                    msg.contains("no more mock responses")
8006                        || msg.contains("timeout")
8007                        || msg.contains("Timeout"),
8008                    "expected timeout-related error, got: {msg}"
8009                );
8010            }
8011        }
8012    }
8013
8014    /// Complex squad integration test exercising:
8015    /// - 3 squad agents with mixed capabilities (tools, plain, failing)
8016    /// - Private blackboard isolation from outer blackboard
8017    /// - Full event capture and audit trail analysis
8018    /// - Tool execution within a squad member (multi-turn agent)
8019    /// - Graceful error handling when one squad member fails
8020    /// - Token roll-up from successful + partial-failure squad members
8021    /// - Event ordering invariants (RunStarted first per agent, SubAgentsDispatched before SubAgentCompleted)
8022    #[tokio::test]
8023    async fn form_squad_complex_with_tools_events_and_failure() {
8024        use crate::agent::blackboard::InMemoryBlackboard;
8025
8026        let outer_bb = Arc::new(InMemoryBlackboard::new());
8027
8028        // The "reviewer" agent gets a dedicated failing provider (empty responses).
8029        let failing_provider = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
8030
8031        // Shared provider serves orchestrator + planner + worker (in call order).
8032        // worker is multi-turn: calls `compute` tool, then produces final text.
8033        let provider = Arc::new(MockProvider::new(vec![
8034            // 1: Orchestrator forms a squad of planner + worker + reviewer
8035            CompletionResponse {
8036                content: vec![ContentBlock::ToolUse {
8037                    id: "orch-call-1".into(),
8038                    name: "form_squad".into(),
8039                    input: json!({
8040                        "tasks": [
8041                            {"agent": "planner", "task": "Create a plan for the analysis"},
8042                            {"agent": "worker", "task": "Compute the metrics"},
8043                            {"agent": "reviewer", "task": "Review all findings"}
8044                        ]
8045                    }),
8046                }],
8047                stop_reason: StopReason::ToolUse,
8048                reasoning: None,
8049                usage: TokenUsage {
8050                    input_tokens: 100,
8051                    output_tokens: 40,
8052                    cache_creation_input_tokens: 5,
8053                    cache_read_input_tokens: 3,
8054                    reasoning_tokens: 0,
8055                },
8056                model: None,
8057            },
8058            // 2: Squad member "planner" responds immediately (single turn)
8059            CompletionResponse {
8060                content: vec![ContentBlock::Text {
8061                    text: "Plan: Step 1 gather data, Step 2 compute, Step 3 review.".into(),
8062                }],
8063                stop_reason: StopReason::EndTurn,
8064                reasoning: None,
8065                usage: TokenUsage {
8066                    input_tokens: 20,
8067                    output_tokens: 15,
8068                    reasoning_tokens: 8,
8069                    ..Default::default()
8070                },
8071                model: None,
8072            },
8073            // 3: Squad member "worker" calls the `compute` tool (turn 1)
8074            CompletionResponse {
8075                content: vec![
8076                    ContentBlock::Text {
8077                        text: "I'll compute the metrics now.".into(),
8078                    },
8079                    ContentBlock::ToolUse {
8080                        id: "worker-call-1".into(),
8081                        name: "compute".into(),
8082                        input: json!({"expression": "42 * 17"}),
8083                    },
8084                ],
8085                stop_reason: StopReason::ToolUse,
8086                reasoning: None,
8087                usage: TokenUsage {
8088                    input_tokens: 25,
8089                    output_tokens: 12,
8090                    ..Default::default()
8091                },
8092                model: None,
8093            },
8094            // 4: Squad member "worker" produces final text after tool result (turn 2)
8095            CompletionResponse {
8096                content: vec![ContentBlock::Text {
8097                    text: "Computation result: 714. Analysis complete.".into(),
8098                }],
8099                stop_reason: StopReason::EndTurn,
8100                reasoning: None,
8101                usage: TokenUsage {
8102                    input_tokens: 35,
8103                    output_tokens: 18,
8104                    ..Default::default()
8105                },
8106                model: None,
8107            },
8108            // 5: (reviewer uses failing_provider, not this queue)
8109            // 6: Orchestrator recovers — synthesizes from partial squad results
8110            CompletionResponse {
8111                content: vec![ContentBlock::Text {
8112                    text: "Squad partial success: plan and computation done, review failed.".into(),
8113                }],
8114                stop_reason: StopReason::EndTurn,
8115                reasoning: None,
8116                usage: TokenUsage {
8117                    input_tokens: 200,
8118                    output_tokens: 60,
8119                    ..Default::default()
8120                },
8121                model: None,
8122            },
8123        ]));
8124
8125        // Capture all events
8126        let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
8127        let events_clone = events.clone();
8128        let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
8129            events_clone.lock().expect("test lock").push(event);
8130        });
8131
8132        // Both planner and worker get the compute tool so shared MockProvider
8133        // response ordering is resilient to JoinSet non-determinism.
8134        let compute_tool: Arc<dyn Tool> = Arc::new(MockTool::new("compute", "714"));
8135
8136        let mut orch = Orchestrator::builder(provider)
8137            .sub_agent_with_tools(
8138                "planner",
8139                "Planning specialist",
8140                "You create plans.",
8141                vec![compute_tool.clone()],
8142            )
8143            .sub_agent_with_tools(
8144                "worker",
8145                "Computation worker",
8146                "You compute metrics.",
8147                vec![compute_tool.clone()],
8148            )
8149            .sub_agent_full(SubAgentConfig {
8150                name: "reviewer".into(),
8151                description: "Review specialist".into(),
8152                system_prompt: "You review findings.".into(),
8153                tools: vec![],
8154                context_strategy: None,
8155                summarize_threshold: None,
8156                tool_timeout: None,
8157                max_tool_output_bytes: None,
8158                max_turns: None,
8159                max_tokens: None,
8160                response_schema: None,
8161                run_timeout: None,
8162                guardrails: vec![],
8163                provider: Some(failing_provider),
8164                reasoning_effort: None,
8165                enable_reflection: None,
8166                tool_output_compression_threshold: None,
8167                max_tools_per_turn: None,
8168                tool_profile: None,
8169                max_identical_tool_calls: None,
8170                max_fuzzy_identical_tool_calls: None,
8171                max_tool_calls_per_turn: None,
8172                session_prune_config: None,
8173                enable_recursive_summarization: None,
8174                reflection_threshold: None,
8175                consolidate_on_exit: None,
8176                workspace: None,
8177                max_total_tokens: None,
8178                audit_trail: None,
8179                audit_user_id: None,
8180                audit_tenant_id: None,
8181                audit_delegation_chain: Vec::new(),
8182                context: Default::default(),
8183            })
8184            .blackboard(outer_bb.clone())
8185            .on_event(on_event)
8186            .build()
8187            .unwrap();
8188
8189        let output = orch.run("Analyze the system performance").await.unwrap();
8190
8191        // === 1. Verify final output ===
8192        assert_eq!(
8193            output.result,
8194            "Squad partial success: plan and computation done, review failed."
8195        );
8196
8197        // === 2. Verify token roll-up ===
8198        // Orchestrator: 100+200 in, 40+60 out, 5 cache_create, 3 cache_read
8199        // Planner: 20 in, 15 out, 8 reasoning
8200        // Worker: 25+35 in, 12+18 out
8201        // Reviewer: 0 (failed before any response)
8202        let expected_input = 100 + 200 + 20 + 25 + 35;
8203        let expected_output = 40 + 60 + 15 + 12 + 18;
8204        assert_eq!(
8205            output.tokens_used.input_tokens, expected_input,
8206            "input tokens should sum orchestrator + planner + worker (reviewer failed)"
8207        );
8208        assert_eq!(
8209            output.tokens_used.output_tokens, expected_output,
8210            "output tokens should sum orchestrator + planner + worker"
8211        );
8212        assert_eq!(
8213            output.tokens_used.reasoning_tokens, 8,
8214            "reasoning tokens should come from planner"
8215        );
8216        assert_eq!(
8217            output.tokens_used.cache_creation_input_tokens, 5,
8218            "cache creation tokens from orchestrator"
8219        );
8220        assert_eq!(
8221            output.tokens_used.cache_read_input_tokens, 3,
8222            "cache read tokens from orchestrator"
8223        );
8224
8225        // === 3. Verify blackboard writes ===
8226        // Squad result should be on the outer blackboard under "squad:planner+worker+reviewer"
8227        let squad_key = "squad:planner+worker+reviewer";
8228        let squad_val = outer_bb.read(squad_key).await.unwrap();
8229        assert!(
8230            squad_val.is_some(),
8231            "outer blackboard should have squad result under '{squad_key}'"
8232        );
8233        let squad_text = squad_val.unwrap().to_string();
8234        // Planner's result should appear
8235        assert!(
8236            squad_text.contains("Plan: Step 1"),
8237            "squad result should include planner's output"
8238        );
8239        // Worker's result should appear
8240        assert!(
8241            squad_text.contains("Computation result: 714"),
8242            "squad result should include worker's output"
8243        );
8244        // Reviewer's error should appear
8245        assert!(
8246            squad_text.contains("Error"),
8247            "squad result should include reviewer's error"
8248        );
8249
8250        // Private blackboard keys should NOT be on the outer blackboard
8251        assert!(
8252            outer_bb.read("agent:planner").await.unwrap().is_none(),
8253            "outer blackboard should NOT have agent:planner"
8254        );
8255        assert!(
8256            outer_bb.read("agent:worker").await.unwrap().is_none(),
8257            "outer blackboard should NOT have agent:worker"
8258        );
8259
8260        // === 4. Analyze events ===
8261        let events = events.lock().expect("test lock");
8262
8263        fn agent_of(e: &AgentEvent) -> &str {
8264            match e {
8265                AgentEvent::RunStarted { agent, .. }
8266                | AgentEvent::TurnStarted { agent, .. }
8267                | AgentEvent::LlmResponse { agent, .. }
8268                | AgentEvent::Reasoning { agent, .. }
8269                | AgentEvent::ToolCallStarted { agent, .. }
8270                | AgentEvent::ToolCallCompleted { agent, .. }
8271                | AgentEvent::RunCompleted { agent, .. }
8272                | AgentEvent::RunFailed { agent, .. }
8273                | AgentEvent::SubAgentsDispatched { agent, .. }
8274                | AgentEvent::SubAgentCompleted { agent, .. }
8275                | AgentEvent::ApprovalRequested { agent, .. }
8276                | AgentEvent::ApprovalDecision { agent, .. }
8277                | AgentEvent::ContextSummarized { agent, .. }
8278                | AgentEvent::GuardrailDenied { agent, .. }
8279                | AgentEvent::GuardrailWarned { agent, .. }
8280                | AgentEvent::RequestRouted { agent, .. }
8281                | AgentEvent::GateFired { agent, .. }
8282                | AgentEvent::RetryAttempt { agent, .. }
8283                | AgentEvent::DoomLoopDetected { agent, .. }
8284                | AgentEvent::FuzzyDoomLoopDetected { agent, .. }
8285                | AgentEvent::AutoCompactionTriggered { agent, .. }
8286                | AgentEvent::SessionPruned { agent, .. }
8287                | AgentEvent::ModelEscalated { agent, .. }
8288                | AgentEvent::BudgetExceeded { agent, .. }
8289                | AgentEvent::AgentSpawned { agent, .. }
8290                | AgentEvent::KillSwitchActivated { agent, .. }
8291                | AgentEvent::ToolNameRepaired { agent, .. } => agent,
8292                AgentEvent::SensorEventProcessed { sensor_name, .. } => sensor_name,
8293                AgentEvent::StoryUpdated { story_id, .. } => story_id,
8294                AgentEvent::TaskRouted { decision, .. } => decision,
8295                AgentEvent::WorkflowNodeStarted { node, .. }
8296                | AgentEvent::WorkflowNodeCompleted { node, .. }
8297                | AgentEvent::WorkflowNodeFailed { node, .. } => node,
8298            }
8299        }
8300
8301        fn event_type(e: &AgentEvent) -> &'static str {
8302            match e {
8303                AgentEvent::RunStarted { .. } => "RunStarted",
8304                AgentEvent::TurnStarted { .. } => "TurnStarted",
8305                AgentEvent::LlmResponse { .. } => "LlmResponse",
8306                AgentEvent::Reasoning { .. } => "Reasoning",
8307                AgentEvent::ToolCallStarted { .. } => "ToolCallStarted",
8308                AgentEvent::ToolCallCompleted { .. } => "ToolCallCompleted",
8309                AgentEvent::RunCompleted { .. } => "RunCompleted",
8310                AgentEvent::RunFailed { .. } => "RunFailed",
8311                AgentEvent::SubAgentsDispatched { .. } => "SubAgentsDispatched",
8312                AgentEvent::SubAgentCompleted { .. } => "SubAgentCompleted",
8313                AgentEvent::ApprovalRequested { .. } => "ApprovalRequested",
8314                AgentEvent::ApprovalDecision { .. } => "ApprovalDecision",
8315                AgentEvent::ContextSummarized { .. } => "ContextSummarized",
8316                AgentEvent::GuardrailDenied { .. } => "GuardrailDenied",
8317                AgentEvent::GuardrailWarned { .. } => "GuardrailWarned",
8318                AgentEvent::RequestRouted { .. } => "RequestRouted",
8319                AgentEvent::GateFired { .. } => "GateFired",
8320                AgentEvent::RetryAttempt { .. } => "RetryAttempt",
8321                AgentEvent::DoomLoopDetected { .. } => "DoomLoopDetected",
8322                AgentEvent::FuzzyDoomLoopDetected { .. } => "FuzzyDoomLoopDetected",
8323                AgentEvent::AutoCompactionTriggered { .. } => "AutoCompactionTriggered",
8324                AgentEvent::SessionPruned { .. } => "SessionPruned",
8325                AgentEvent::SensorEventProcessed { .. } => "SensorEventProcessed",
8326                AgentEvent::StoryUpdated { .. } => "StoryUpdated",
8327                AgentEvent::TaskRouted { .. } => "TaskRouted",
8328                AgentEvent::ModelEscalated { .. } => "ModelEscalated",
8329                AgentEvent::BudgetExceeded { .. } => "BudgetExceeded",
8330                AgentEvent::AgentSpawned { .. } => "AgentSpawned",
8331                AgentEvent::KillSwitchActivated { .. } => "KillSwitchActivated",
8332                AgentEvent::WorkflowNodeStarted { .. } => "WorkflowNodeStarted",
8333                AgentEvent::WorkflowNodeCompleted { .. } => "WorkflowNodeCompleted",
8334                AgentEvent::WorkflowNodeFailed { .. } => "WorkflowNodeFailed",
8335                AgentEvent::ToolNameRepaired { .. } => "ToolNameRepaired",
8336            }
8337        }
8338
8339        let event_summary: Vec<String> = events
8340            .iter()
8341            .enumerate()
8342            .map(|(i, e)| format!("{i}: [{:>12}] {}", agent_of(e), event_type(e)))
8343            .collect();
8344        let event_log = event_summary.join("\n");
8345
8346        // 4a. Verify events from expected agents
8347        let agents_seen: std::collections::HashSet<&str> = events.iter().map(agent_of).collect();
8348        assert!(
8349            agents_seen.contains("orchestrator"),
8350            "missing orchestrator events.\n{event_log}"
8351        );
8352        // At least planner or worker should be present (JoinSet order non-deterministic)
8353        let has_planner = agents_seen.contains("planner");
8354        let has_worker = agents_seen.contains("worker");
8355        assert!(
8356            has_planner || has_worker,
8357            "should have events from at least one successful squad member.\n{event_log}"
8358        );
8359
8360        // 4b. SubAgentsDispatched should fire exactly once (from squad-leader)
8361        let dispatched: Vec<&AgentEvent> = events
8362            .iter()
8363            .filter(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }))
8364            .collect();
8365        assert_eq!(
8366            dispatched.len(),
8367            1,
8368            "expected exactly 1 SubAgentsDispatched event.\n{event_log}"
8369        );
8370        match dispatched[0] {
8371            AgentEvent::SubAgentsDispatched { agents, agent } => {
8372                assert_eq!(
8373                    agent, "squad-leader",
8374                    "form_squad uses 'squad-leader' label"
8375                );
8376                assert_eq!(agents.len(), 3, "should dispatch 3 squad members");
8377                assert!(agents.contains(&"planner".to_string()));
8378                assert!(agents.contains(&"worker".to_string()));
8379                assert!(agents.contains(&"reviewer".to_string()));
8380            }
8381            _ => unreachable!(),
8382        }
8383
8384        // 4c. SubAgentCompleted events: 3 per-agent + 1 aggregate = 4 total
8385        let completed: Vec<&AgentEvent> = events
8386            .iter()
8387            .filter(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }))
8388            .collect();
8389        assert_eq!(
8390            completed.len(),
8391            4,
8392            "expected 4 SubAgentCompleted events (3 per-agent + 1 aggregate).\n{event_log}"
8393        );
8394
8395        // Per-agent completions: planner and worker succeed, reviewer fails
8396        let per_agent: Vec<&AgentEvent> = completed
8397            .iter()
8398            .filter(|e| {
8399                matches!(e, AgentEvent::SubAgentCompleted { agent, .. }
8400                    if !agent.starts_with("squad["))
8401            })
8402            .copied()
8403            .collect();
8404        assert_eq!(per_agent.len(), 3, "3 per-agent completion events");
8405
8406        // Find reviewer completion — should have success=false
8407        let reviewer_completed = per_agent.iter().find(
8408            |e| matches!(e, AgentEvent::SubAgentCompleted { agent, .. } if agent == "reviewer"),
8409        );
8410        assert!(
8411            reviewer_completed.is_some(),
8412            "should have reviewer SubAgentCompleted"
8413        );
8414        match reviewer_completed.unwrap() {
8415            AgentEvent::SubAgentCompleted { success, .. } => {
8416                assert!(!success, "reviewer should have failed");
8417            }
8418            _ => unreachable!(),
8419        }
8420
8421        // Find planner completion — should have success=true
8422        let planner_completed = per_agent.iter().find(
8423            |e| matches!(e, AgentEvent::SubAgentCompleted { agent, .. } if agent == "planner"),
8424        );
8425        assert!(
8426            planner_completed.is_some(),
8427            "should have planner SubAgentCompleted"
8428        );
8429        match planner_completed.unwrap() {
8430            AgentEvent::SubAgentCompleted { success, usage, .. } => {
8431                assert!(success, "planner should have succeeded");
8432                assert_eq!(usage.input_tokens, 20);
8433                assert_eq!(usage.output_tokens, 15);
8434                assert_eq!(usage.reasoning_tokens, 8);
8435            }
8436            _ => unreachable!(),
8437        }
8438
8439        // Aggregate squad completion event
8440        let squad_completed = completed.iter().find(|e| {
8441            matches!(e, AgentEvent::SubAgentCompleted { agent, .. }
8442                if agent.starts_with("squad["))
8443        });
8444        assert!(
8445            squad_completed.is_some(),
8446            "should have aggregate squad completion event.\n{event_log}"
8447        );
8448        match squad_completed.unwrap() {
8449            AgentEvent::SubAgentCompleted {
8450                agent,
8451                success,
8452                usage,
8453            } => {
8454                assert!(
8455                    agent.contains("planner")
8456                        && agent.contains("worker")
8457                        && agent.contains("reviewer"),
8458                    "aggregate label should list all agents: {agent}"
8459                );
8460                assert!(
8461                    !success,
8462                    "aggregate should be false because reviewer failed"
8463                );
8464                // Aggregate tokens = planner + worker + reviewer(0)
8465                assert_eq!(usage.input_tokens, 20 + 25 + 35, "aggregate input tokens");
8466                assert_eq!(usage.output_tokens, 15 + 12 + 18, "aggregate output tokens");
8467            }
8468            _ => unreachable!(),
8469        }
8470
8471        // 4d. Verify worker had tool events (ToolCallStarted + ToolCallCompleted for "compute")
8472        let tool_started: Vec<&AgentEvent> = events
8473            .iter()
8474            .filter(|e| {
8475                matches!(e, AgentEvent::ToolCallStarted { tool_name, .. } if tool_name == "compute")
8476            })
8477            .collect();
8478        // Due to JoinSet non-determinism, either planner or worker may call compute
8479        // (both have it). We just verify it was called.
8480        assert!(
8481            !tool_started.is_empty(),
8482            "should have at least one compute ToolCallStarted.\n{event_log}"
8483        );
8484
8485        let tool_completed: Vec<&AgentEvent> = events
8486            .iter()
8487            .filter(|e| {
8488                matches!(e, AgentEvent::ToolCallCompleted { tool_name, .. } if tool_name == "compute")
8489            })
8490            .collect();
8491        assert!(
8492            !tool_completed.is_empty(),
8493            "should have at least one compute ToolCallCompleted.\n{event_log}"
8494        );
8495        match tool_completed[0] {
8496            AgentEvent::ToolCallCompleted {
8497                output, is_error, ..
8498            } => {
8499                assert!(!is_error, "compute tool should succeed");
8500                assert!(
8501                    output.contains("714"),
8502                    "compute output should be '714', got: {output}"
8503                );
8504            }
8505            _ => unreachable!(),
8506        }
8507
8508        // 4e. RunStarted is first event for each agent
8509        for agent_name in &["orchestrator", "planner", "worker"] {
8510            let agent_events: Vec<&AgentEvent> = events
8511                .iter()
8512                .filter(|e| agent_of(e) == *agent_name)
8513                .collect();
8514            if !agent_events.is_empty() {
8515                assert!(
8516                    matches!(agent_events[0], AgentEvent::RunStarted { .. }),
8517                    "first event for '{agent_name}' should be RunStarted, got: {:?}\n{event_log}",
8518                    agent_events[0]
8519                );
8520            }
8521        }
8522
8523        // 4f. Reviewer should have RunStarted then RunFailed (provider error)
8524        let reviewer_events: Vec<&AgentEvent> = events
8525            .iter()
8526            .filter(|e| agent_of(e) == "reviewer")
8527            .collect();
8528        if !reviewer_events.is_empty() {
8529            assert!(
8530                matches!(reviewer_events[0], AgentEvent::RunStarted { .. }),
8531                "reviewer first event should be RunStarted"
8532            );
8533            // Check for RunFailed
8534            let has_failed = reviewer_events
8535                .iter()
8536                .any(|e| matches!(e, AgentEvent::RunFailed { .. }));
8537            assert!(
8538                has_failed,
8539                "reviewer should have a RunFailed event.\n{event_log}"
8540            );
8541        }
8542
8543        // 4g. SubAgentsDispatched appears before any SubAgentCompleted
8544        let dispatch_idx = events
8545            .iter()
8546            .position(|e| matches!(e, AgentEvent::SubAgentsDispatched { .. }));
8547        let first_completed_idx = events
8548            .iter()
8549            .position(|e| matches!(e, AgentEvent::SubAgentCompleted { .. }));
8550        if let (Some(d), Some(c)) = (dispatch_idx, first_completed_idx) {
8551            assert!(
8552                d < c,
8553                "SubAgentsDispatched (idx {d}) should precede SubAgentCompleted (idx {c})\n{event_log}"
8554            );
8555        }
8556
8557        // 4h. LlmResponse events should carry model info from MockProvider
8558        let llm_responses: Vec<&AgentEvent> = events
8559            .iter()
8560            .filter(|e| matches!(e, AgentEvent::LlmResponse { .. }))
8561            .collect();
8562        assert!(
8563            !llm_responses.is_empty(),
8564            "should have LlmResponse events.\n{event_log}"
8565        );
8566        for lr in &llm_responses {
8567            match lr {
8568                AgentEvent::LlmResponse { model, .. } => {
8569                    assert_eq!(
8570                        model.as_deref(),
8571                        Some("mock-model-v1"),
8572                        "LlmResponse should carry provider model name"
8573                    );
8574                }
8575                _ => unreachable!(),
8576            }
8577        }
8578
8579        // === 5. Verify total event count is reasonable ===
8580        // Orchestrator: RunStarted, TurnStarted, LlmResponse(squad call), ToolCallStarted(form_squad),
8581        //              ToolCallCompleted(form_squad), TurnStarted, LlmResponse(synthesis), RunCompleted = ~8
8582        // Planner: RunStarted, TurnStarted, LlmResponse, RunCompleted = ~4
8583        // Worker: RunStarted, TurnStarted, LlmResponse, ToolCallStarted(compute),
8584        //         ToolCallCompleted(compute), TurnStarted, LlmResponse, RunCompleted = ~8
8585        // Reviewer: RunStarted, TurnStarted(?), RunFailed = ~2-3
8586        // + SubAgentsDispatched + 3 SubAgentCompleted + 1 aggregate = 5
8587        // Total ~25-28 events
8588        assert!(
8589            events.len() >= 15,
8590            "expected at least 15 events for complex squad test, got {}.\n{event_log}",
8591            events.len(),
8592        );
8593    }
8594
8595    #[test]
8596    fn build_rejects_empty_sub_agent_name() {
8597        let provider = Arc::new(MockProvider::new(vec![]));
8598        let result = Orchestrator::builder(provider)
8599            .sub_agent("", "Empty name agent", "prompt")
8600            .build();
8601        match result {
8602            Err(Error::Config(msg)) => {
8603                assert!(
8604                    msg.contains("must not be empty"),
8605                    "expected empty name error, got: {msg}"
8606                );
8607            }
8608            Err(other) => panic!("expected Config error, got: {other:?}"),
8609            Ok(_) => panic!("expected error for empty sub-agent name"),
8610        }
8611    }
8612
8613    #[tokio::test]
8614    async fn instruction_text_wired_to_orchestrator_system_prompt() {
8615        // CapturingProvider records the system prompt from LLM calls.
8616        struct CapturingProvider {
8617            captured_systems: Mutex<Vec<String>>,
8618        }
8619        impl LlmProvider for CapturingProvider {
8620            async fn complete(
8621                &self,
8622                request: CompletionRequest,
8623            ) -> Result<CompletionResponse, Error> {
8624                self.captured_systems
8625                    .lock()
8626                    .expect("lock")
8627                    .push(request.system.clone());
8628                // Return end_turn immediately so the orchestrator finishes
8629                Ok(CompletionResponse {
8630                    content: vec![ContentBlock::Text {
8631                        text: "Task complete.".into(),
8632                    }],
8633                    stop_reason: StopReason::EndTurn,
8634                    reasoning: None,
8635                    usage: TokenUsage::default(),
8636                    model: None,
8637                })
8638            }
8639        }
8640
8641        let provider = Arc::new(CapturingProvider {
8642            captured_systems: Mutex::new(Vec::new()),
8643        });
8644        let mut orchestrator = Orchestrator::builder(provider.clone())
8645            .sub_agent("agent-a", "Does things", "You are agent A.")
8646            .instruction_text("Always verify your work.")
8647            .build()
8648            .unwrap();
8649
8650        let _output = orchestrator.run("test task").await.unwrap();
8651        let systems = provider.captured_systems.lock().expect("lock").clone();
8652        // The orchestrator's own LLM call should have instructions prepended
8653        assert!(!systems.is_empty(), "should have at least one LLM call");
8654        let orchestrator_system = &systems[0];
8655        assert!(
8656            orchestrator_system.contains("# Project Instructions"),
8657            "orchestrator system prompt should contain instruction header"
8658        );
8659        assert!(
8660            orchestrator_system.contains("Always verify your work."),
8661            "orchestrator system prompt should contain instruction text"
8662        );
8663    }
8664
8665    #[tokio::test]
8666    async fn permission_rules_propagate_to_sub_agents() {
8667        // Orchestrator-level permission rules should apply to sub-agent tool calls.
8668        // Here we deny "bash" at the orchestrator level and verify the worker's
8669        // bash call is rejected.
8670        let provider = Arc::new(MockProvider::new(vec![
8671            // 1: Orchestrator delegates to worker
8672            CompletionResponse {
8673                content: vec![ContentBlock::ToolUse {
8674                    id: "orch-1".into(),
8675                    name: "delegate_task".into(),
8676                    input: json!({
8677                        "tasks": [{"agent": "worker", "task": "run a bash command"}]
8678                    }),
8679                }],
8680                stop_reason: StopReason::ToolUse,
8681                reasoning: None,
8682                usage: TokenUsage::default(),
8683                model: None,
8684            },
8685            // 2: Worker tries to call bash (will be denied by permission rules)
8686            CompletionResponse {
8687                content: vec![ContentBlock::ToolUse {
8688                    id: "worker-1".into(),
8689                    name: "bash".into(),
8690                    input: json!({"command": "echo hello"}),
8691                }],
8692                stop_reason: StopReason::ToolUse,
8693                reasoning: None,
8694                usage: TokenUsage::default(),
8695                model: None,
8696            },
8697            // 3: Worker sees the denial and responds
8698            CompletionResponse {
8699                content: vec![ContentBlock::Text {
8700                    text: "Bash was denied.".into(),
8701                }],
8702                stop_reason: StopReason::EndTurn,
8703                reasoning: None,
8704                usage: TokenUsage::default(),
8705                model: None,
8706            },
8707            // 4: Orchestrator synthesis
8708            CompletionResponse {
8709                content: vec![ContentBlock::Text {
8710                    text: "Worker reported bash was denied.".into(),
8711                }],
8712                stop_reason: StopReason::EndTurn,
8713                reasoning: None,
8714                usage: TokenUsage::default(),
8715                model: None,
8716            },
8717        ]));
8718
8719        let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
8720        let events_clone = events.clone();
8721        let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
8722            events_clone.lock().expect("test lock").push(event);
8723        });
8724
8725        let deny_bash = crate::agent::permission::PermissionRuleset::new(vec![
8726            crate::agent::permission::PermissionRule {
8727                tool: "bash".into(),
8728                pattern: "*".into(),
8729                action: crate::agent::permission::PermissionAction::Deny,
8730            },
8731        ]);
8732
8733        let bash_tool: Arc<dyn Tool> = Arc::new(MockTool::new("bash", "executed"));
8734
8735        let mut orch = Orchestrator::builder(provider)
8736            .sub_agent_with_tools("worker", "Bash worker", "You run bash.", vec![bash_tool])
8737            .permission_rules(deny_bash)
8738            .on_event(on_event)
8739            .build()
8740            .unwrap();
8741
8742        let output = orch.run("run bash via worker").await.unwrap();
8743        assert_eq!(output.result, "Worker reported bash was denied.");
8744
8745        // The worker should NOT have ToolCallStarted/ToolCallCompleted for bash
8746        // because permission-denied calls skip event emission.
8747        let events = events.lock().expect("test lock");
8748        let worker_tool_events: Vec<_> = events
8749            .iter()
8750            .filter(|e| {
8751                matches!(
8752                    e,
8753                    AgentEvent::ToolCallStarted { agent, tool_name, .. }
8754                    | AgentEvent::ToolCallCompleted { agent, tool_name, .. }
8755                        if agent == "worker" && tool_name == "bash"
8756                )
8757            })
8758            .collect();
8759        assert!(
8760            worker_tool_events.is_empty(),
8761            "bash tool calls in worker should be denied (no events emitted), got: {worker_tool_events:?}"
8762        );
8763    }
8764
8765    #[tokio::test]
8766    async fn permission_rules_propagate_to_squad_members() {
8767        // Same test but via form_squad path.
8768        let provider = Arc::new(MockProvider::new(vec![
8769            // 1: Orchestrator forms a squad
8770            CompletionResponse {
8771                content: vec![ContentBlock::ToolUse {
8772                    id: "orch-1".into(),
8773                    name: "form_squad".into(),
8774                    input: json!({
8775                        "tasks": [
8776                            {"agent": "alpha", "task": "run bash"},
8777                            {"agent": "beta", "task": "say hello"}
8778                        ]
8779                    }),
8780                }],
8781                stop_reason: StopReason::ToolUse,
8782                reasoning: None,
8783                usage: TokenUsage::default(),
8784                model: None,
8785            },
8786            // 2: alpha tries bash (denied)
8787            CompletionResponse {
8788                content: vec![ContentBlock::ToolUse {
8789                    id: "alpha-1".into(),
8790                    name: "bash".into(),
8791                    input: json!({"command": "ls"}),
8792                }],
8793                stop_reason: StopReason::ToolUse,
8794                reasoning: None,
8795                usage: TokenUsage::default(),
8796                model: None,
8797            },
8798            // 3: alpha sees denial
8799            CompletionResponse {
8800                content: vec![ContentBlock::Text {
8801                    text: "Bash denied.".into(),
8802                }],
8803                stop_reason: StopReason::EndTurn,
8804                reasoning: None,
8805                usage: TokenUsage::default(),
8806                model: None,
8807            },
8808            // 4: beta just responds (no bash)
8809            CompletionResponse {
8810                content: vec![ContentBlock::Text {
8811                    text: "Hello!".into(),
8812                }],
8813                stop_reason: StopReason::EndTurn,
8814                reasoning: None,
8815                usage: TokenUsage::default(),
8816                model: None,
8817            },
8818            // 5: Orchestrator synthesis
8819            CompletionResponse {
8820                content: vec![ContentBlock::Text {
8821                    text: "Squad done.".into(),
8822                }],
8823                stop_reason: StopReason::EndTurn,
8824                reasoning: None,
8825                usage: TokenUsage::default(),
8826                model: None,
8827            },
8828        ]));
8829
8830        let deny_bash = crate::agent::permission::PermissionRuleset::new(vec![
8831            crate::agent::permission::PermissionRule {
8832                tool: "bash".into(),
8833                pattern: "*".into(),
8834                action: crate::agent::permission::PermissionAction::Deny,
8835            },
8836        ]);
8837
8838        let bash_tool: Arc<dyn Tool> = Arc::new(MockTool::new("bash", "executed"));
8839
8840        let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
8841        let events_clone = events.clone();
8842        let on_event: Arc<OnEvent> = Arc::new(move |event: AgentEvent| {
8843            events_clone.lock().expect("test lock").push(event);
8844        });
8845
8846        let mut orch = Orchestrator::builder(provider)
8847            .sub_agent_with_tools(
8848                "alpha",
8849                "Alpha agent",
8850                "You run bash.",
8851                vec![bash_tool.clone()],
8852            )
8853            .sub_agent("beta", "Beta agent", "You say hello.")
8854            .permission_rules(deny_bash)
8855            .on_event(on_event)
8856            .build()
8857            .unwrap();
8858
8859        let output = orch.run("form a squad").await.unwrap();
8860        assert_eq!(output.result, "Squad done.");
8861
8862        // Alpha's bash call should be denied — no ToolCallStarted/Completed events for bash
8863        let events = events.lock().expect("test lock");
8864        let bash_events: Vec<_> = events
8865            .iter()
8866            .filter(|e| {
8867                matches!(
8868                    e,
8869                    AgentEvent::ToolCallStarted { tool_name, .. }
8870                    | AgentEvent::ToolCallCompleted { tool_name, .. }
8871                        if tool_name == "bash"
8872                )
8873            })
8874            .collect();
8875        assert!(
8876            bash_events.is_empty(),
8877            "bash tool calls in squad should be denied (no events), got: {bash_events:?}"
8878        );
8879    }
8880
8881    #[test]
8882    fn workspace_propagates_from_builder_to_sub_agents() {
8883        let provider = Arc::new(MockProvider::new(vec![]));
8884        let builder = Orchestrator::builder(provider)
8885            .workspace("/shared/workspace")
8886            .sub_agent_full(SubAgentConfig {
8887                name: "agent1".into(),
8888                description: "test".into(),
8889                workspace: None, // Should inherit from builder
8890                ..Default::default()
8891            });
8892
8893        let agent = &builder.sub_agents[0];
8894        assert_eq!(
8895            agent.workspace.as_deref(),
8896            Some(std::path::Path::new("/shared/workspace")),
8897            "sub-agent should inherit workspace from builder"
8898        );
8899    }
8900
8901    #[test]
8902    fn sub_agent_workspace_overrides_builder() {
8903        let provider = Arc::new(MockProvider::new(vec![]));
8904        let builder = Orchestrator::builder(provider)
8905            .workspace("/shared/workspace")
8906            .sub_agent_full(SubAgentConfig {
8907                name: "agent1".into(),
8908                description: "test".into(),
8909                workspace: Some("/custom/workspace".into()),
8910                ..Default::default()
8911            });
8912
8913        let agent = &builder.sub_agents[0];
8914        assert_eq!(
8915            agent.workspace.as_deref(),
8916            Some(std::path::Path::new("/custom/workspace")),
8917            "sub-agent should use its own workspace over builder's"
8918        );
8919    }
8920
8921    #[test]
8922    fn no_workspace_when_builder_has_none() {
8923        let provider = Arc::new(MockProvider::new(vec![]));
8924        let builder = Orchestrator::builder(provider).sub_agent_full(SubAgentConfig {
8925            name: "agent1".into(),
8926            description: "test".into(),
8927            workspace: None,
8928            ..Default::default()
8929        });
8930
8931        let agent = &builder.sub_agents[0];
8932        assert!(
8933            agent.workspace.is_none(),
8934            "sub-agent should have no workspace when builder has none"
8935        );
8936    }
8937
8938    #[test]
8939    fn multi_agent_prompt_enabled_by_default() {
8940        let provider = Arc::new(MockProvider::new(vec![]));
8941        let builder = Orchestrator::builder(provider);
8942        assert!(builder.multi_agent_prompt);
8943    }
8944
8945    #[test]
8946    fn multi_agent_prompt_can_be_disabled() {
8947        let provider = Arc::new(MockProvider::new(vec![]));
8948        let builder = Orchestrator::builder(provider).multi_agent_prompt(false);
8949        assert!(!builder.multi_agent_prompt);
8950    }
8951
8952    #[test]
8953    fn build_injects_collab_prompt_when_enabled() {
8954        let provider = Arc::new(MockProvider::new(vec![]));
8955        let mut builder = Orchestrator::builder(provider).sub_agent(
8956            "writer",
8957            "Writes content",
8958            "You are a writer.",
8959        );
8960        // Before build, prompt is not yet injected
8961        assert!(
8962            !builder.sub_agents[0]
8963                .system_prompt
8964                .contains("MULTI-AGENT COLLABORATION PROTOCOL")
8965        );
8966        // Manually apply the same logic as build() to inspect
8967        if builder.multi_agent_prompt {
8968            for agent in &mut builder.sub_agents {
8969                agent
8970                    .system_prompt
8971                    .push_str(&crate::agent::prompts::render_collab_prompt(
8972                        &agent.name,
8973                        &agent.description,
8974                    ));
8975            }
8976        }
8977        assert!(
8978            builder.sub_agents[0]
8979                .system_prompt
8980                .contains("MULTI-AGENT COLLABORATION PROTOCOL")
8981        );
8982        assert!(builder.sub_agents[0].system_prompt.contains("`writer`"));
8983        assert!(
8984            builder.sub_agents[0]
8985                .system_prompt
8986                .contains("Writes content")
8987        );
8988    }
8989
8990    #[test]
8991    fn build_omits_collab_prompt_when_disabled() {
8992        let provider = Arc::new(MockProvider::new(vec![]));
8993        let mut builder = Orchestrator::builder(provider)
8994            .multi_agent_prompt(false)
8995            .sub_agent("writer", "Writes content", "You are a writer.");
8996        // Manually apply the same logic as build()
8997        if builder.multi_agent_prompt {
8998            for agent in &mut builder.sub_agents {
8999                agent
9000                    .system_prompt
9001                    .push_str(&crate::agent::prompts::render_collab_prompt(
9002                        &agent.name,
9003                        &agent.description,
9004                    ));
9005            }
9006        }
9007        assert!(
9008            !builder.sub_agents[0]
9009                .system_prompt
9010                .contains("MULTI-AGENT COLLABORATION PROTOCOL")
9011        );
9012        assert_eq!(builder.sub_agents[0].system_prompt, "You are a writer.");
9013    }
9014
9015    // ── SpawnAgentTool tests ──
9016
9017    fn make_spawn_config() -> crate::types::SpawnConfig {
9018        crate::types::SpawnConfig {
9019            max_spawned_agents: 3,
9020            tool_allowlist: vec![],
9021            max_turns: 5,
9022            max_tokens: 1024,
9023            max_total_tokens: 10_000,
9024        }
9025    }
9026
9027    fn build_spawn_tool(
9028        provider: Arc<MockProvider>,
9029        config: crate::types::SpawnConfig,
9030        tools: Vec<Arc<dyn Tool>>,
9031    ) -> SpawnAgentTool {
9032        let mut tool_pool = std::collections::HashMap::new();
9033        for tool in &tools {
9034            let name = tool.definition().name;
9035            if config.tool_allowlist.contains(&name) {
9036                tool_pool.insert(name, tool.clone());
9037            }
9038        }
9039        let cached_definition = SpawnAgentTool::build_definition(&config);
9040        SpawnAgentTool {
9041            shared_provider: Arc::new(BoxedProvider::from_arc(provider)),
9042            spawn_config: config,
9043            tool_pool,
9044            spawn_count: Arc::new(std::sync::atomic::AtomicU32::new(0)),
9045            spawned_names: Arc::new(Mutex::new(std::collections::HashSet::new())),
9046            accumulated_tokens: Arc::new(Mutex::new(TokenUsage::default())),
9047            permission_rules: crate::agent::permission::PermissionRuleset::default(),
9048            shared_memory: None,
9049            memory_namespace_prefix: None,
9050            on_event: None,
9051            lsp_manager: None,
9052            observability_mode: crate::agent::observability::ObservabilityMode::Production,
9053            workspace: None,
9054            guardrails: vec![],
9055            audit_trail: None,
9056            audit_user_id: None,
9057            audit_tenant_id: None,
9058            audit_delegation_chain: vec![],
9059            cached_definition,
9060            tenant_tracker: None,
9061            on_approval: None,
9062            learned_permissions: None,
9063        }
9064    }
9065
9066    #[tokio::test]
9067    async fn spawn_agent_basic_execution() {
9068        let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9069            content: vec![ContentBlock::Text {
9070                text: "Tax analysis complete.".into(),
9071            }],
9072            usage: TokenUsage {
9073                input_tokens: 50,
9074                output_tokens: 20,
9075                ..Default::default()
9076            },
9077            stop_reason: StopReason::EndTurn,
9078            reasoning: None,
9079            model: None,
9080        }]));
9081
9082        let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9083
9084        let result = tool
9085            .spawn(SpawnAgentInput {
9086                name: "tax_specialist".into(),
9087                system_prompt: "You are a tax law expert.".into(),
9088                tools: vec![],
9089                task: "Analyze tax implications.".into(),
9090            })
9091            .await
9092            .unwrap();
9093
9094        assert!(!result.is_error);
9095        assert!(result.content.contains("Tax analysis complete."));
9096        assert!(result.content.contains("spawn:tax_specialist"));
9097        assert_eq!(
9098            tool.spawn_count.load(std::sync::atomic::Ordering::Relaxed),
9099            1
9100        );
9101    }
9102
9103    #[tokio::test]
9104    async fn spawn_agent_rejects_invalid_name() {
9105        let provider = Arc::new(MockProvider::new(vec![]));
9106        let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9107
9108        let invalid_names = vec![
9109            "Tax-Specialist",
9110            "123abc",
9111            "",
9112            "has spaces",
9113            "../path",
9114            "a/b",
9115            "UPPER",
9116        ];
9117
9118        for name in invalid_names {
9119            let result = tool
9120                .spawn(SpawnAgentInput {
9121                    name: name.into(),
9122                    system_prompt: "test".into(),
9123                    tools: vec![],
9124                    task: "test".into(),
9125                })
9126                .await
9127                .unwrap();
9128            assert!(
9129                result.is_error,
9130                "expected error for name '{name}', got success: {}",
9131                result.content
9132            );
9133            assert!(
9134                result.content.contains("Invalid agent name"),
9135                "expected 'Invalid agent name' in error for name '{name}', got: {}",
9136                result.content
9137            );
9138        }
9139    }
9140
9141    #[tokio::test]
9142    async fn spawn_agent_rejects_duplicate_name() {
9143        let provider = Arc::new(MockProvider::new(vec![
9144            CompletionResponse {
9145                content: vec![ContentBlock::Text {
9146                    text: "done".into(),
9147                }],
9148                usage: TokenUsage::default(),
9149                stop_reason: StopReason::EndTurn,
9150                reasoning: None,
9151                model: None,
9152            },
9153            // Second response shouldn't be needed
9154        ]));
9155        let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9156
9157        // First spawn succeeds
9158        let r1 = tool
9159            .spawn(SpawnAgentInput {
9160                name: "helper".into(),
9161                system_prompt: "test".into(),
9162                tools: vec![],
9163                task: "test".into(),
9164            })
9165            .await
9166            .unwrap();
9167        assert!(!r1.is_error);
9168
9169        // Second spawn with same name fails
9170        let r2 = tool
9171            .spawn(SpawnAgentInput {
9172                name: "helper".into(),
9173                system_prompt: "test".into(),
9174                tools: vec![],
9175                task: "test".into(),
9176            })
9177            .await
9178            .unwrap();
9179        assert!(r2.is_error);
9180        assert!(r2.content.contains("already used"));
9181    }
9182
9183    #[tokio::test]
9184    async fn spawn_agent_enforces_tool_allowlist() {
9185        let provider = Arc::new(MockProvider::new(vec![]));
9186        let mut config = make_spawn_config();
9187        config.tool_allowlist = vec!["mock_read".into()];
9188
9189        let mock = MockTool::new("mock_read", "file content");
9190        let tool = build_spawn_tool(provider, config, vec![Arc::new(mock)]);
9191
9192        // Request a tool not in the allowlist
9193        let result = tool
9194            .spawn(SpawnAgentInput {
9195                name: "reader".into(),
9196                system_prompt: "test".into(),
9197                tools: vec!["bash".into()],
9198                task: "test".into(),
9199            })
9200            .await
9201            .unwrap();
9202        assert!(result.is_error);
9203        assert!(result.content.contains("not in allowlist"));
9204    }
9205
9206    #[tokio::test]
9207    async fn spawn_agent_count_cap() {
9208        let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9209            content: vec![ContentBlock::Text {
9210                text: "done".into(),
9211            }],
9212            usage: TokenUsage::default(),
9213            stop_reason: StopReason::EndTurn,
9214            reasoning: None,
9215            model: None,
9216        }]));
9217        let mut config = make_spawn_config();
9218        config.max_spawned_agents = 1;
9219        let tool = build_spawn_tool(provider, config, vec![]);
9220
9221        // First spawn succeeds
9222        let r1 = tool
9223            .spawn(SpawnAgentInput {
9224                name: "first".into(),
9225                system_prompt: "test".into(),
9226                tools: vec![],
9227                task: "test".into(),
9228            })
9229            .await
9230            .unwrap();
9231        assert!(!r1.is_error);
9232
9233        // Second spawn hits the cap
9234        let r2 = tool
9235            .spawn(SpawnAgentInput {
9236                name: "second".into(),
9237                system_prompt: "test".into(),
9238                tools: vec![],
9239                task: "test".into(),
9240            })
9241            .await
9242            .unwrap();
9243        assert!(r2.is_error);
9244        assert!(r2.content.contains("Spawn limit reached"));
9245    }
9246
9247    #[tokio::test]
9248    async fn spawn_agent_count_cap_holds_under_concurrent_calls() {
9249        // A1: two concurrent spawn calls with max_spawned_agents=1 must not both
9250        // pass the cap. The previous load-then-check (count incremented only
9251        // after each child finished) let both observe count=0 and overshoot.
9252        let mk = || CompletionResponse {
9253            content: vec![ContentBlock::Text {
9254                text: "done".into(),
9255            }],
9256            usage: TokenUsage::default(),
9257            stop_reason: StopReason::EndTurn,
9258            reasoning: None,
9259            model: None,
9260        };
9261        // Two responses so that, under the buggy code, BOTH spawns could execute.
9262        let provider = Arc::new(MockProvider::new(vec![mk(), mk()]));
9263        let mut config = make_spawn_config();
9264        config.max_spawned_agents = 1;
9265        let tool = build_spawn_tool(provider, config, vec![]);
9266
9267        let (r1, r2) = tokio::join!(
9268            tool.spawn(SpawnAgentInput {
9269                name: "first".into(),
9270                system_prompt: "t".into(),
9271                tools: vec![],
9272                task: "t".into(),
9273            }),
9274            tool.spawn(SpawnAgentInput {
9275                name: "second".into(),
9276                system_prompt: "t".into(),
9277                tools: vec![],
9278                task: "t".into(),
9279            }),
9280        );
9281        let r1 = r1.unwrap();
9282        let r2 = r2.unwrap();
9283        let errors = [&r1, &r2].iter().filter(|r| r.is_error).count();
9284        assert_eq!(
9285            errors, 1,
9286            "exactly one of two concurrent spawns must be rejected by the cap"
9287        );
9288        assert!(
9289            r1.content.contains("Spawn limit reached")
9290                || r2.content.contains("Spawn limit reached")
9291        );
9292    }
9293
9294    #[tokio::test]
9295    async fn spawn_agent_token_budget_enforcement() {
9296        let provider = Arc::new(MockProvider::new(vec![]));
9297        let mut config = make_spawn_config();
9298        config.max_total_tokens = 100;
9299        let tool = build_spawn_tool(provider, config, vec![]);
9300
9301        // Pre-fill token accumulator near the limit
9302        {
9303            let mut acc = tool.accumulated_tokens.lock().unwrap();
9304            *acc = TokenUsage {
9305                input_tokens: 60,
9306                output_tokens: 50,
9307                ..Default::default()
9308            };
9309        }
9310
9311        let result = tool
9312            .spawn(SpawnAgentInput {
9313                name: "spender".into(),
9314                system_prompt: "test".into(),
9315                tools: vec![],
9316                task: "test".into(),
9317            })
9318            .await
9319            .unwrap();
9320        assert!(result.is_error);
9321        assert!(result.content.contains("budget exhausted"));
9322    }
9323
9324    #[tokio::test]
9325    async fn spawn_agent_no_delegation_tools() {
9326        // Verify that delegation tools are stripped from the pool even if
9327        // somehow included in the allowlist
9328        let provider = Arc::new(MockProvider::new(vec![]));
9329        let mut config = make_spawn_config();
9330        config.tool_allowlist = vec!["mock_read".into()];
9331
9332        let mock = MockTool::new("mock_read", "content");
9333        let tools: Vec<Arc<dyn Tool>> = vec![Arc::new(mock)];
9334
9335        let mut tool_pool = std::collections::HashMap::new();
9336        for t in &tools {
9337            let name = t.definition().name;
9338            if config.tool_allowlist.contains(&name) {
9339                tool_pool.insert(name, t.clone());
9340            }
9341        }
9342        // Manually inject a delegation tool to prove it gets stripped in build()
9343        tool_pool.insert(
9344            "delegate_task".into(),
9345            Arc::new(MockTool::new("delegate_task", "bad")),
9346        );
9347        tool_pool.remove("delegate_task");
9348        tool_pool.remove("form_squad");
9349        tool_pool.remove("spawn_agent");
9350
9351        // Pool should only have mock_read
9352        assert!(tool_pool.contains_key("mock_read"));
9353        assert!(!tool_pool.contains_key("delegate_task"));
9354        assert!(!tool_pool.contains_key("form_squad"));
9355        assert!(!tool_pool.contains_key("spawn_agent"));
9356        drop(provider);
9357    }
9358
9359    #[tokio::test]
9360    async fn spawn_agent_emits_events() {
9361        let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(vec![]));
9362        let events_clone = events.clone();
9363
9364        let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9365            content: vec![ContentBlock::Text {
9366                text: "result".into(),
9367            }],
9368            usage: TokenUsage::default(),
9369            stop_reason: StopReason::EndTurn,
9370            reasoning: None,
9371            model: None,
9372        }]));
9373
9374        let mut tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9375        tool.on_event = Some(Arc::new(move |e: AgentEvent| {
9376            events_clone.lock().unwrap().push(e);
9377        }));
9378
9379        let _ = tool
9380            .spawn(SpawnAgentInput {
9381                name: "emitter".into(),
9382                system_prompt: "test".into(),
9383                tools: vec![],
9384                task: "test".into(),
9385            })
9386            .await;
9387
9388        let events = events.lock().unwrap();
9389        let spawned = events
9390            .iter()
9391            .any(|e| matches!(e, AgentEvent::AgentSpawned { spawned_name, .. } if spawned_name == "spawn:emitter"));
9392        assert!(spawned, "expected AgentSpawned event");
9393
9394        let completed = events
9395            .iter()
9396            .any(|e| matches!(e, AgentEvent::SubAgentCompleted { agent, success, .. } if agent == "spawn:emitter" && *success));
9397        assert!(completed, "expected SubAgentCompleted event");
9398    }
9399
9400    #[tokio::test]
9401    async fn spawn_agent_empty_tools() {
9402        // An empty tools array should create a reasoning-only agent that works
9403        let provider = Arc::new(MockProvider::new(vec![CompletionResponse {
9404            content: vec![ContentBlock::Text {
9405                text: "Pure reasoning response".into(),
9406            }],
9407            usage: TokenUsage::default(),
9408            stop_reason: StopReason::EndTurn,
9409            reasoning: None,
9410            model: None,
9411        }]));
9412
9413        let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9414
9415        let result = tool
9416            .spawn(SpawnAgentInput {
9417                name: "thinker".into(),
9418                system_prompt: "You are a reasoning agent.".into(),
9419                tools: vec![],
9420                task: "Think about this.".into(),
9421            })
9422            .await
9423            .unwrap();
9424        assert!(!result.is_error);
9425        assert!(result.content.contains("Pure reasoning response"));
9426    }
9427
9428    #[tokio::test]
9429    async fn spawn_agent_prompt_too_long() {
9430        let provider = Arc::new(MockProvider::new(vec![]));
9431        let tool = build_spawn_tool(provider, make_spawn_config(), vec![]);
9432
9433        let long_prompt = "x".repeat(SPAWN_MAX_PROMPT_BYTES + 1);
9434        let result = tool
9435            .spawn(SpawnAgentInput {
9436                name: "verbose".into(),
9437                system_prompt: long_prompt,
9438                tools: vec![],
9439                task: "test".into(),
9440            })
9441            .await
9442            .unwrap();
9443        assert!(result.is_error);
9444        assert!(result.content.contains("System prompt too long"));
9445    }
9446
9447    #[test]
9448    fn spawn_config_validation_rejects_zero_agents() {
9449        let toml_str = r#"
9450[provider]
9451name = "anthropic"
9452model = "claude-sonnet-4-20250514"
9453
9454[orchestrator.spawn]
9455max_spawned_agents = 0
9456"#;
9457        let err = crate::config::HeartbitConfig::from_toml(toml_str).unwrap_err();
9458        assert!(
9459            err.to_string()
9460                .contains("max_spawned_agents must be at least 1"),
9461            "err: {err}"
9462        );
9463    }
9464
9465    #[test]
9466    fn spawn_config_validation_rejects_zero_turns() {
9467        let toml_str = r#"
9468[provider]
9469name = "anthropic"
9470model = "claude-sonnet-4-20250514"
9471
9472[orchestrator.spawn]
9473max_turns = 0
9474"#;
9475        let err = crate::config::HeartbitConfig::from_toml(toml_str).unwrap_err();
9476        assert!(
9477            err.to_string().contains("max_turns must be at least 1"),
9478            "err: {err}"
9479        );
9480    }
9481
9482    #[test]
9483    fn spawn_config_from_toml() {
9484        let toml_str = r#"
9485[provider]
9486name = "anthropic"
9487model = "claude-sonnet-4-20250514"
9488
9489[orchestrator.spawn]
9490max_spawned_agents = 5
9491tool_allowlist = ["read", "grep", "bash"]
9492max_turns = 20
9493max_tokens = 8192
9494max_total_tokens = 100000
9495"#;
9496        let config: crate::config::HeartbitConfig = toml::from_str(toml_str).unwrap();
9497        let spawn = config.orchestrator.spawn.as_ref().unwrap();
9498        assert_eq!(spawn.max_spawned_agents, 5);
9499        assert_eq!(spawn.tool_allowlist, vec!["read", "grep", "bash"]);
9500        assert_eq!(spawn.max_turns, 20);
9501        assert_eq!(spawn.max_tokens, 8192);
9502        assert_eq!(spawn.max_total_tokens, 100_000);
9503    }
9504
9505    #[test]
9506    fn spawn_disabled_by_default() {
9507        let toml_str = r#"
9508[provider]
9509name = "anthropic"
9510model = "claude-sonnet-4-20250514"
9511"#;
9512        let config: crate::config::HeartbitConfig = toml::from_str(toml_str).unwrap();
9513        assert!(config.orchestrator.spawn.is_none());
9514    }
9515
9516    #[test]
9517    fn spawn_config_invalid_tool_rejected_at_build() {
9518        let provider = Arc::new(MockProvider::new(vec![]));
9519        let mut config = make_spawn_config();
9520        config.tool_allowlist = vec!["nonexistent_tool".into()];
9521
9522        let result = Orchestrator::builder(provider)
9523            .sub_agent("worker", "does work", "You work.")
9524            .spawn_config(config, vec![]) // empty builtins
9525            .build();
9526
9527        match result {
9528            Err(e) => {
9529                let msg = e.to_string();
9530                assert!(
9531                    msg.contains("nonexistent_tool"),
9532                    "expected error mentioning the bad tool, got: {msg}"
9533                );
9534            }
9535            Ok(_) => panic!("expected build error for invalid tool in allowlist"),
9536        }
9537    }
9538
9539    #[test]
9540    fn spawn_tool_definition_includes_allowlist() {
9541        let mut config = make_spawn_config();
9542        config.tool_allowlist = vec!["read".into(), "grep".into()];
9543        let def = SpawnAgentTool::build_definition(&config);
9544        assert_eq!(def.name, "spawn_agent");
9545        assert!(def.description.contains("read, grep"));
9546        assert!(def.description.contains("3 agents max"));
9547    }
9548
9549    #[tokio::test]
9550    async fn spawn_system_prompt_added_when_configured() {
9551        // When spawn is configured, the orchestrator should have spawn_agent in its tool list.
9552        // We verify by running the orchestrator and inspecting the LLM request.
9553        use std::sync::Arc;
9554
9555        let provider = Arc::new(MockProvider::new(vec![
9556            // Orchestrator response (EndTurn, no tool calls)
9557            CompletionResponse {
9558                content: vec![ContentBlock::Text {
9559                    text: "No delegation needed.".into(),
9560                }],
9561                usage: TokenUsage::default(),
9562                stop_reason: StopReason::EndTurn,
9563                reasoning: None,
9564                model: None,
9565            },
9566        ]));
9567
9568        let config = make_spawn_config();
9569        let mut orchestrator = Orchestrator::builder(provider)
9570            .sub_agent("worker", "does work", "You work.")
9571            .spawn_config(config, vec![])
9572            .build()
9573            .unwrap();
9574
9575        let output = orchestrator.run("test task").await.unwrap();
9576        // If spawn_agent tool is present, the orchestrator prompt should mention it
9577        assert!(output.result.contains("No delegation needed."));
9578    }
9579
9580    /// Verify that when `OrchestratorBuilder::tenant_tracker` is set, both the
9581    /// orchestrator's own LLM calls AND sub-agent LLM calls are tracked by the
9582    /// same `TenantTokenTracker`.
9583    ///
9584    /// `adjust()` in the runner is a reconciliation call — it only mutates state
9585    /// for tenants that already have a map entry (created by a prior `reserve()`).
9586    /// We simulate the daemon's submit-time admission check by doing a
9587    /// `reserve()`+`drop()` before the run, which creates the entry. The `adjust()`
9588    /// calls during the run then accumulate into `in_flight` and `high_water`.
9589    ///
9590    /// ## Discriminating test design
9591    ///
9592    /// We make the sub-agent's token count **dominate** so `high_water` is only
9593    /// large when the sub-agent is wired to the tracker:
9594    ///
9595    ///   - Orchestrator turn 1 (delegates): 5 input + 5 output = 10 cumulative.
9596    ///   - Sub-agent turn   (completes):   400 input + 100 output = 500 total.
9597    ///   - Orchestrator turn 2 (synth):     2 input + 2 output — orch cumulative = 14.
9598    ///
9599    /// **With** sub-agent wired: peak in-flight = 10 (orch) + 500 (sub) = 510 → `high_water = 510`.
9600    /// **Without** sub-agent wired: sub-agent never adjusts → peak = 14 → `high_water = 14`.
9601    ///
9602    /// Asserting `high_water >= 500` is only true when the sub-agent is wired.
9603    #[tokio::test(flavor = "multi_thread")]
9604    async fn orchestrator_propagates_tenant_tracker_to_sub_agents() {
9605        use crate::agent::tenant_tracker::TenantTokenTracker;
9606        use crate::auth::TenantScope;
9607
9608        let tracker = Arc::new(TenantTokenTracker::new(1_000_000));
9609
9610        // Simulate the admission-check reservation (creates the map entry).
9611        // Drop immediately — this matches the daemon's submit-time semantics.
9612        let scope = TenantScope::new("tenant-abc");
9613        drop(tracker.reserve(&scope, 100_000).unwrap());
9614
9615        // Verify the entry now exists with in_flight=0.
9616        let initial_snap = tracker.snapshot();
9617        assert_eq!(initial_snap.len(), 1, "entry must exist after reserve+drop");
9618        assert_eq!(initial_snap[0].1.in_flight, 0);
9619
9620        // Orchestrator turn 1 tokens: tiny (so the sub-agent dominates high_water).
9621        // Sub-agent tokens: large (500) so high_water > 500 only when wired.
9622        // Orchestrator turn 3 tokens: tiny (stays below 500).
9623        let provider = Arc::new(MockProvider::new(vec![
9624            // Orchestrator turn 1: delegates to "worker" (5+5=10 tokens)
9625            CompletionResponse {
9626                content: vec![ContentBlock::ToolUse {
9627                    id: "tt-call-1".into(),
9628                    name: "delegate_task".into(),
9629                    input: json!({
9630                        "tasks": [{"agent": "worker", "task": "do work"}]
9631                    }),
9632                }],
9633                stop_reason: StopReason::ToolUse,
9634                reasoning: None,
9635                usage: TokenUsage {
9636                    input_tokens: 5,
9637                    output_tokens: 5,
9638                    ..Default::default()
9639                },
9640                model: None,
9641            },
9642            // Sub-agent "worker": large token usage (400+100=500)
9643            CompletionResponse {
9644                content: vec![ContentBlock::Text {
9645                    text: "Work done.".into(),
9646                }],
9647                stop_reason: StopReason::EndTurn,
9648                reasoning: None,
9649                usage: TokenUsage {
9650                    input_tokens: 400,
9651                    output_tokens: 100,
9652                    ..Default::default()
9653                },
9654                model: None,
9655            },
9656            // Orchestrator turn 2: synthesises (2+2=4 tokens; cumulative=14)
9657            CompletionResponse {
9658                content: vec![ContentBlock::Text {
9659                    text: "All done.".into(),
9660                }],
9661                stop_reason: StopReason::EndTurn,
9662                reasoning: None,
9663                usage: TokenUsage {
9664                    input_tokens: 2,
9665                    output_tokens: 2,
9666                    ..Default::default()
9667                },
9668                model: None,
9669            },
9670        ]));
9671
9672        // audit_user_context MUST be set before sub_agent() so the tenant ID
9673        // propagates into the sub-agent def (the builder copies it at call time).
9674        let mut orch = Orchestrator::builder(provider)
9675            .audit_user_context("user-1", "tenant-abc")
9676            .sub_agent("worker", "Does work", "You work.")
9677            .tenant_tracker(tracker.clone())
9678            .build()
9679            .unwrap();
9680
9681        orch.run("do work").await.unwrap();
9682
9683        // high_water check: with wiring, peak = orch(10) + sub-agent(500) = 510.
9684        // Without wiring, peak = orch turn-2 cumulative (14) — well below 500.
9685        let snap_mid = tracker.snapshot();
9686        let state_mid = snap_mid
9687            .iter()
9688            .find(|(tid, _)| tid == "tenant-abc")
9689            .map(|(_, s)| s.clone())
9690            .expect("entry for 'tenant-abc' should still exist after the run");
9691
9692        assert!(
9693            state_mid.high_water >= 500,
9694            "high_water ({}) must be >= 500 (sub-agent's 500 tokens); \
9695             if it is < 500, the sub-agent runner is not propagating the tracker",
9696            state_mid.high_water
9697        );
9698
9699        // Drop orch to release the orchestrator runner's RAII hold, then confirm
9700        // in_flight returns to 0.
9701        drop(orch);
9702
9703        let snap_final = tracker.snapshot();
9704        let state_final = snap_final
9705            .iter()
9706            .find(|(tid, _)| tid == "tenant-abc")
9707            .map(|(_, s)| s.clone())
9708            .expect("entry should still exist after drop");
9709
9710        assert_eq!(
9711            state_final.in_flight, 0,
9712            "in_flight should return to 0 after all runners are dropped"
9713        );
9714    }
9715}