Skip to main content

agent_works/
builder.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use agent_base::{AgentResult, AgentRuntime, Tool};
6
7#[cfg(feature = "multi_agent")]
8use crate::multi_agent::{MultiAgentConfig, MultiAgentRuntime};
9
10#[cfg(feature = "skill")]
11use crate::skill::{LazySkillPrompter, Skill, SkillPrompter};
12
13/// Read agent instruction files and build a combined prompt section.
14///
15/// Files are read in order; later entries have higher priority and appear
16/// first in the output. Missing files are silently skipped.
17fn build_agent_instructions_prompt(paths: &[PathBuf]) -> Option<String> {
18    let mut sections = Vec::new();
19
20    for path in paths {
21        if let Ok(content) = std::fs::read_to_string(path) {
22            let trimmed = content.trim();
23            if !trimmed.is_empty() {
24                sections.push(trimmed.to_string());
25            }
26        }
27    }
28
29    if sections.is_empty() {
30        return None;
31    }
32
33    // Later entries (higher priority) appear first
34    sections.reverse();
35    Some(sections.join("\n\n---\n\n"))
36}
37
38/// Factory type for creating multi-agent tools from a MultiAgentRuntime.
39#[cfg(feature = "multi_agent")]
40pub type MultiAgentToolFactory =
41    Arc<dyn Fn(Arc<MultiAgentRuntime>) -> Vec<Arc<dyn Tool>> + Send + Sync>;
42
43/// Factory type for creating a skill detail tool from skills and a tool name.
44#[cfg(feature = "skill")]
45pub type SkillDetailToolFactory =
46    Arc<dyn Fn(Vec<Arc<dyn Skill>>, String) -> Arc<dyn Tool> + Send + Sync>;
47
48/// Factory type for creating a list-skills tool from a SkillRegistry.
49#[cfg(feature = "skill")]
50pub type ListSkillsToolFactory =
51    Arc<dyn Fn(Arc<crate::skill::SkillRegistry>) -> Arc<dyn Tool> + Send + Sync>;
52
53pub struct AgentBuilder {
54    inner: agent_base::AgentBuilder,
55    client: Arc<dyn agent_base::llm_trait::LlmProvider>,
56    system_prompt: Option<String>,
57    tool_names: HashSet<String>,
58    /// Business tools to pass to child agents (all registered tools).
59    business_tools: Vec<Arc<dyn Tool>>,
60    /// Multi-agent configuration (None = disabled).
61    #[cfg(feature = "multi_agent")]
62    multi_agent_config: Option<MultiAgentConfig>,
63    /// Factory to create multi-agent tools (injected by phi-kernel-tools).
64    #[cfg(feature = "multi_agent")]
65    multi_agent_tool_factory: Option<MultiAgentToolFactory>,
66    /// Error recovery (stored for multi-agent child inheritance).
67    error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
68    /// Language preference.
69    language: Option<agent_base::Language>,
70    #[cfg(feature = "skill")]
71    skills: Vec<Arc<dyn Skill>>,
72    #[cfg(feature = "skill")]
73    skill_prompter: Option<Arc<dyn SkillPrompter>>,
74    #[cfg(feature = "skill")]
75    skill_detail_tool_name: String,
76    /// Optional: inject a custom skill-detail tool (old tool-based mode).
77    /// In default prompt-injection mode, the LLM reads `SKILL.md` via
78    /// `read_file` — no dedicated detail tool is needed.
79    #[cfg(feature = "skill")]
80    skill_detail_tool_factory: Option<SkillDetailToolFactory>,
81    #[cfg(feature = "skill")]
82    list_skills_tool_factory: Option<ListSkillsToolFactory>,
83    #[cfg(feature = "skill")]
84    disable_skill_prompt_injection: bool,
85    /// Optional inline context compactor for the react loop.
86    context_compactor: Option<Arc<dyn agent_base::ContextCompaction>>,
87    /// Paths to agent instruction files (e.g., CLAUDE.md, INSTRUCTIONS.md).
88    /// These files are read at build time and appended to the system prompt.
89    /// Supports multiple paths (user-level + project-level); later entries
90    /// have higher priority and appear first in the prompt.
91    agent_instructions_paths: Vec<PathBuf>,
92}
93
94impl AgentBuilder {
95    pub fn new(client: Arc<dyn agent_base::llm_trait::LlmProvider>) -> Self {
96        Self {
97            inner: agent_base::AgentBuilder::new(client.clone()),
98            client,
99            system_prompt: None,
100            tool_names: HashSet::new(),
101            business_tools: Vec::new(),
102            #[cfg(feature = "multi_agent")]
103            multi_agent_config: None,
104            #[cfg(feature = "multi_agent")]
105            multi_agent_tool_factory: None,
106            error_recovery: None,
107            language: None,
108            #[cfg(feature = "skill")]
109            skills: Vec::new(),
110            #[cfg(feature = "skill")]
111            skill_prompter: None,
112            #[cfg(feature = "skill")]
113            skill_detail_tool_name: "get_skill_detail".to_string(),
114            #[cfg(feature = "skill")]
115            skill_detail_tool_factory: None,
116            #[cfg(feature = "skill")]
117            list_skills_tool_factory: None,
118            #[cfg(feature = "skill")]
119            disable_skill_prompt_injection: false,
120            context_compactor: None,
121            agent_instructions_paths: Vec::new(),
122        }
123    }
124
125    /// Enable multi-agent support with the given configuration.
126    ///
127    /// Also sets the tool factory to create the 5 multi-agent tools.
128    /// Callers should use `phi_kernel_tools::multi_agent::create_all_tools` as the factory.
129    #[cfg(feature = "multi_agent")]
130    pub fn with_multi_agent(mut self, config: MultiAgentConfig) -> Self {
131        self.multi_agent_config = Some(config);
132        self
133    }
134
135    /// Disable multi-agent support.
136    ///
137    /// Removes any previously set multi-agent configuration. No multi-agent tools
138    /// will be registered and the system prompt will not mention multi-agent capabilities.
139    #[cfg(feature = "multi_agent")]
140    pub fn without_multi_agent(mut self) -> Self {
141        self.multi_agent_config = None;
142        self.multi_agent_tool_factory = None;
143        self
144    }
145
146    /// Set a custom factory for creating multi-agent tools.
147    ///
148    /// The factory receives the `MultiAgentRuntime` and returns the tools to register.
149    /// If not set but multi-agent is enabled, no tools are registered (caller must
150    /// set this for multi-agent to work).
151    #[cfg(feature = "multi_agent")]
152    pub fn with_multi_agent_tool_factory(mut self, factory: MultiAgentToolFactory) -> Self {
153        self.multi_agent_tool_factory = Some(factory);
154        self
155    }
156
157    /// Set a custom factory for creating the skill detail tool.
158    ///
159    /// The factory receives the skill list and tool name, and returns the tool.
160    /// If not set but skills are registered, no detail tool is added.
161    #[cfg(feature = "skill")]
162    pub fn with_skill_detail_tool_factory(mut self, factory: SkillDetailToolFactory) -> Self {
163        self.skill_detail_tool_factory = Some(factory);
164        self
165    }
166
167    /// Set a custom factory for creating the list-skills tool.
168    ///
169    /// The factory receives the SkillRegistry and returns the tool.
170    #[cfg(feature = "skill")]
171    pub fn with_list_skills_tool_factory(mut self, factory: ListSkillsToolFactory) -> Self {
172        self.list_skills_tool_factory = Some(factory);
173        self
174    }
175
176    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
177        let prompt = prompt.into();
178        self.inner = self.inner.system_prompt(prompt.clone());
179        self.system_prompt = Some(prompt);
180        self
181    }
182
183    pub fn enable_thought(self, enable: bool) -> Self {
184        Self {
185            inner: self.inner.enable_thought(enable),
186            ..self
187        }
188    }
189
190    pub fn reasoning(self, config: agent_base::ReasoningConfig) -> Self {
191        Self {
192            inner: self.inner.reasoning(config),
193            ..self
194        }
195    }
196
197    pub fn enable_thinking(self, enable: bool) -> Self {
198        Self {
199            inner: self.inner.enable_thinking(enable),
200            ..self
201        }
202    }
203
204    pub fn thinking_budget(self, budget: u64) -> Self {
205        Self {
206            inner: self.inner.thinking_budget(budget),
207            ..self
208        }
209    }
210
211    pub fn tool_timeout(self, timeout_ms: u64) -> Self {
212        Self {
213            inner: self.inner.tool_timeout(timeout_ms),
214            ..self
215        }
216    }
217
218    pub fn max_tool_output_chars(self, max_chars: usize) -> Self {
219        Self {
220            inner: self.inner.max_tool_output_chars(max_chars),
221            ..self
222        }
223    }
224
225    pub fn max_sessions(self, max: usize) -> Self {
226        Self {
227            inner: self.inner.max_sessions(max),
228            ..self
229        }
230    }
231
232    pub fn max_turns_per_session(self, max: usize) -> Self {
233        Self {
234            inner: self.inner.max_turns_per_session(max),
235            ..self
236        }
237    }
238
239    pub fn execution_max_turns(self, max: u32) -> Self {
240        Self {
241            inner: self.inner.execution_max_turns(max),
242            ..self
243        }
244    }
245
246    pub fn max_message_tokens(self, max: usize) -> Self {
247        Self {
248            inner: self.inner.max_message_tokens(max),
249            ..self
250        }
251    }
252
253    pub fn register_tool(mut self, tool: impl Tool + 'static) -> Self {
254        let tool_arc: Arc<dyn Tool> = Arc::new(tool);
255        self.tool_names.insert(tool_arc.name().to_string());
256        self.business_tools.push(tool_arc.clone());
257        self.inner = self.inner.register_tool_arc(tool_arc);
258        self
259    }
260
261    pub fn register_tool_arc(mut self, tool: Arc<dyn Tool>) -> Self {
262        self.tool_names.insert(tool.name().to_string());
263        self.business_tools.push(tool.clone());
264        self.inner = self.inner.register_tool_arc(tool);
265        self
266    }
267
268    pub fn approval_handler(self, handler: Arc<dyn agent_base::ApprovalHandler>) -> Self {
269        Self {
270            inner: self.inner.approval_handler(handler),
271            ..self
272        }
273    }
274
275    pub fn tool_policy(self, policy: Arc<dyn agent_base::ToolPolicy>) -> Self {
276        Self {
277            inner: self.inner.tool_policy(policy),
278            ..self
279        }
280    }
281
282    pub fn middleware(self, mw: impl agent_base::Middleware + 'static) -> Self {
283        Self {
284            inner: self.inner.middleware(mw),
285            ..self
286        }
287    }
288
289    pub fn guard(self, guard: impl agent_base::ReactLoopGuard + 'static) -> Self {
290        Self {
291            inner: self.inner.guard(guard),
292            ..self
293        }
294    }
295
296    pub fn context_window(self, max_tokens: usize) -> Self {
297        Self {
298            inner: self.inner.context_window(max_tokens),
299            ..self
300        }
301    }
302
303    pub fn context_window_manager(self, manager: agent_base::ContextWindowManager) -> Self {
304        Self {
305            inner: self.inner.context_window_manager(manager),
306            ..self
307        }
308    }
309
310    pub fn response_format(self, format: agent_base::ResponseFormat) -> Self {
311        Self {
312            inner: self.inner.response_format(format),
313            ..self
314        }
315    }
316
317    pub fn llm_retry(self, retry: agent_base::RetryConfig) -> Self {
318        Self {
319            inner: self.inner.llm_retry(retry),
320            ..self
321        }
322    }
323
324    pub fn session_store(self, store: Arc<dyn agent_base::SessionStore>) -> Self {
325        Self {
326            inner: self.inner.session_store(store),
327            ..self
328        }
329    }
330
331    pub fn error_recovery(mut self, recovery: Arc<dyn agent_base::ToolErrorRecovery>) -> Self {
332        self.error_recovery = Some(recovery.clone());
333        self.inner = self.inner.error_recovery(recovery);
334        self
335    }
336
337    pub fn tool_error_retry_prompt(self, prompt: impl Into<String>) -> Self {
338        Self {
339            inner: self.inner.tool_error_retry_prompt(prompt),
340            ..self
341        }
342    }
343
344    pub fn language(mut self, language: agent_base::Language) -> Self {
345        self.language = Some(language.clone());
346        self.inner = self.inner.language(language);
347        self
348    }
349
350    /// Set an inline context compactor for the react loop.
351    ///
352    /// When set, the react loop will check token count after each tool turn
353    /// and compact the session if it exceeds the configured threshold.
354    pub fn context_compactor(mut self, compactor: Arc<dyn agent_base::ContextCompaction>) -> Self {
355        self.context_compactor = Some(compactor);
356        self
357    }
358
359    pub fn event_bus_capacity(self, capacity: usize) -> Self {
360        Self {
361            inner: self.inner.event_bus_capacity(capacity),
362            ..self
363        }
364    }
365
366    pub fn session_id_generator(
367        self,
368        generator: Arc<dyn agent_base::types::SessionIdGenerator>,
369    ) -> Self {
370        Self {
371            inner: self.inner.session_id_generator(generator),
372            ..self
373        }
374    }
375
376    /// Conditionally apply a transformation when `value` is `Some`.
377    ///
378    /// This is a convenience for option-chaining builder patterns:
379    ///
380    /// ```ignore
381    /// builder.apply_if(args.thinking_budget, |b, budget| b.thinking_budget(budget))
382    /// ```
383    pub fn apply_if<T>(self, value: Option<T>, f: impl FnOnce(Self, T) -> Self) -> Self {
384        match value {
385            Some(v) => f(self, v),
386            None => self,
387        }
388    }
389
390    #[cfg(feature = "skill")]
391    pub fn register_skill(mut self, skill: impl Skill + 'static) -> Self {
392        self.skills.push(Arc::new(skill));
393        self
394    }
395
396    #[cfg(feature = "skill")]
397    pub fn register_skills(mut self, skills: Vec<Arc<dyn Skill>>) -> Self {
398        self.skills.extend(skills);
399        self
400    }
401
402    #[cfg(feature = "skill")]
403    pub fn skill_prompter(mut self, prompter: Arc<dyn SkillPrompter>) -> Self {
404        self.skill_prompter = Some(prompter);
405        self
406    }
407
408    #[cfg(feature = "skill")]
409    pub fn disable_skill_prompt_injection(mut self) -> Self {
410        self.disable_skill_prompt_injection = true;
411        self
412    }
413
414    /// Set paths to agent instruction files (e.g., CLAUDE.md, INSTRUCTIONS.md).
415    ///
416    /// These files are read at build time and appended to the system prompt.
417    /// Supports multiple paths (user-level + project-level); later entries
418    /// have higher priority and appear first in the prompt.
419    ///
420    /// Example:
421    /// ```rust,ignore
422    /// use std::path::PathBuf;
423    /// use agent_works::AgentBuilder;
424    /// let builder = AgentBuilder::new();
425    /// let builder = builder.agent_instructions_paths(vec![
426    ///     PathBuf::from("~/.claude/CLAUDE.md"),  // user-level
427    ///     PathBuf::from(".claude/CLAUDE.md"),     // project-level
428    /// ]);
429    /// ```
430    pub fn agent_instructions_paths(mut self, paths: Vec<PathBuf>) -> Self {
431        self.agent_instructions_paths = paths;
432        self
433    }
434
435    #[cfg(feature = "skill")]
436    pub fn skill_detail_tool_name(mut self, name: impl Into<String>) -> Self {
437        self.skill_detail_tool_name = name.into();
438        self
439    }
440
441    // ── Build ──
442
443    pub fn build(self) -> AgentResult<AgentRuntime> {
444        #[cfg(feature = "skill")]
445        {
446            self.build_with_skills()
447        }
448        #[cfg(not(feature = "skill"))]
449        {
450            self.build_inner()
451        }
452    }
453
454    /// Build the runtime and return the multi-agent runtime (if enabled).
455    ///
456    /// Same as [`build`](Self::build) but also returns the `MultiAgentRuntime`
457    /// created during setup. Callers that need to start the watcher task or
458    /// access the mailbox should use this method.
459    #[cfg(feature = "multi_agent")]
460    pub fn build_with_ma(self) -> AgentResult<(AgentRuntime, Option<Arc<MultiAgentRuntime>>)> {
461        #[cfg(feature = "skill")]
462        {
463            self.build_with_skills_ma()
464        }
465        #[cfg(not(feature = "skill"))]
466        {
467            self.build_inner_ma()
468        }
469    }
470
471    #[allow(dead_code, unused_mut)]
472    fn build_inner(mut self) -> AgentResult<AgentRuntime> {
473        // Inject default guard if none was set by the consumer.
474        // Uses the same LLM client for the judge (enabled by default).
475        if self.inner.get_guard().is_none() {
476            self.inner = self
477                .inner
478                .guard(crate::guard::DefaultGuard::with_llm_client(
479                    crate::guard::DefaultGuardConfig::default(),
480                    self.client.clone(),
481                ));
482        }
483
484        // Inject agent instruction files (e.g., CLAUDE.md) into system prompt
485        if !self.agent_instructions_paths.is_empty()
486            && let Some(instructions) =
487                build_agent_instructions_prompt(&self.agent_instructions_paths)
488        {
489            tracing::info!(
490                paths = ?self.agent_instructions_paths,
491                instructions_len = instructions.len(),
492                "injecting agent instructions into system prompt"
493            );
494            let new_prompt = match self.system_prompt.take() {
495                Some(existing) => format!("{}\n\n---\n\n{}", existing, instructions),
496                None => instructions,
497            };
498            self.system_prompt = Some(new_prompt.clone());
499            self.inner = self.inner.system_prompt(new_prompt);
500        }
501
502        #[cfg(feature = "multi_agent")]
503        let lang = self.language.clone().unwrap_or_default();
504        #[cfg(feature = "multi_agent")]
505        let business_tools = std::mem::take(&mut self.business_tools);
506        #[cfg(feature = "multi_agent")]
507        let error_recovery = self.error_recovery.clone();
508        #[cfg(feature = "multi_agent")]
509        let tool_names = self.tool_names.clone();
510
511        #[cfg(feature = "multi_agent")]
512        let ma_config = self.multi_agent_config.clone();
513        #[cfg(feature = "multi_agent")]
514        let ma_tool_factory = self.multi_agent_tool_factory.take();
515
516        // Inject multi-agent prompt before build
517        #[cfg(feature = "multi_agent")]
518        if ma_config.as_ref().map(|c| c.enabled).unwrap_or(false) {
519            let ma_prompt = build_multi_agent_system_prompt();
520            let new_prompt = match self.system_prompt.take() {
521                Some(existing) => format!("{}\n\n---\n\n{}", existing, ma_prompt),
522                None => ma_prompt,
523            };
524            self.inner = self.inner.system_prompt(new_prompt);
525        }
526
527        // Inject inline context compactor if set
528        if let Some(compactor) = self.context_compactor.take() {
529            self.inner = self.inner.context_compactor(compactor);
530        }
531
532        let runtime = self.inner.build()?;
533
534        // Post-build: register multi-agent tools if enabled and factory is set
535        #[cfg(feature = "multi_agent")]
536        if let Some(config) = ma_config
537            && config.enabled
538        {
539            setup_multi_agent(
540                &runtime,
541                config,
542                lang,
543                business_tools,
544                error_recovery,
545                &tool_names,
546                ma_tool_factory,
547            )?;
548        }
549
550        Ok(runtime)
551    }
552
553    /// Like `build_inner` but also returns the `MultiAgentRuntime`.
554    #[cfg(feature = "multi_agent")]
555    #[allow(dead_code, unused_mut)]
556    fn build_inner_ma(mut self) -> AgentResult<(AgentRuntime, Option<Arc<MultiAgentRuntime>>)> {
557        if self.inner.get_guard().is_none() {
558            self.inner = self
559                .inner
560                .guard(crate::guard::DefaultGuard::with_llm_client(
561                    crate::guard::DefaultGuardConfig::default(),
562                    self.client.clone(),
563                ));
564        }
565
566        let lang = self.language.clone().unwrap_or_default();
567        let business_tools = std::mem::take(&mut self.business_tools);
568        let error_recovery = self.error_recovery.clone();
569        let tool_names = self.tool_names.clone();
570        let ma_config = self.multi_agent_config.clone();
571        let ma_tool_factory = self.multi_agent_tool_factory.take();
572
573        if ma_config.as_ref().map(|c| c.enabled).unwrap_or(false) {
574            let ma_prompt = build_multi_agent_system_prompt();
575            let new_prompt = match self.system_prompt.take() {
576                Some(existing) => format!("{}\n\n---\n\n{}", existing, ma_prompt),
577                None => ma_prompt,
578            };
579            self.inner = self.inner.system_prompt(new_prompt);
580        }
581
582        if let Some(compactor) = self.context_compactor.take() {
583            self.inner = self.inner.context_compactor(compactor);
584        }
585
586        let runtime = self.inner.build()?;
587
588        let ma_runtime_out = if let Some(config) = ma_config
589            && config.enabled
590        {
591            Some(setup_multi_agent(
592                &runtime,
593                config,
594                lang,
595                business_tools,
596                error_recovery,
597                &tool_names,
598                ma_tool_factory,
599            )?)
600        } else {
601            None
602        };
603
604        Ok((runtime, ma_runtime_out))
605    }
606
607    /// Build the runtime with skill support.
608    ///
609    /// # Runtime requirement
610    ///
611    /// This method uses [`tokio::task::block_in_place`] to populate the skill
612    /// registry from a synchronous context. It **requires** a multi-threaded
613    /// tokio runtime. Calling it on a `#[tokio::main]` single-threaded
614    /// (`current_thread`) runtime will panic.
615    ///
616    /// The phi-agent CLI and all examples use the default multi-threaded runtime,
617    /// so this is safe in practice.
618    #[cfg(feature = "skill")]
619    fn build_with_skills(mut self) -> AgentResult<AgentRuntime> {
620        // Inject default guard if none was set by the consumer.
621        // Uses the same LLM client for the judge (enabled by default).
622        if self.inner.get_guard().is_none() {
623            self.inner = self
624                .inner
625                .guard(crate::guard::DefaultGuard::with_llm_client(
626                    crate::guard::DefaultGuardConfig::default(),
627                    self.client.clone(),
628                ));
629        }
630
631        // Inject agent instruction files (e.g., CLAUDE.md) into system prompt
632        if !self.agent_instructions_paths.is_empty()
633            && let Some(instructions) =
634                build_agent_instructions_prompt(&self.agent_instructions_paths)
635        {
636            tracing::info!(
637                paths = ?self.agent_instructions_paths,
638                instructions_len = instructions.len(),
639                "injecting agent instructions into system prompt"
640            );
641            let new_prompt = match self.system_prompt.take() {
642                Some(existing) => format!("{}\n\n---\n\n{}", existing, instructions),
643                None => instructions,
644            };
645            self.system_prompt = Some(new_prompt.clone());
646            self.inner = self.inner.system_prompt(new_prompt);
647        }
648
649        let mut ab = self.inner;
650        #[cfg(feature = "multi_agent")]
651        let lang = self.language.clone().unwrap_or_default();
652        #[cfg(feature = "multi_agent")]
653        let business_tools = std::mem::take(&mut self.business_tools);
654        #[cfg(feature = "multi_agent")]
655        let error_recovery = self.error_recovery.clone();
656        #[cfg(feature = "multi_agent")]
657        let tool_names = self.tool_names.clone();
658
659        #[cfg(feature = "multi_agent")]
660        let ma_config = self.multi_agent_config.clone();
661        #[cfg(feature = "multi_agent")]
662        let ma_tool_factory = self.multi_agent_tool_factory.take();
663
664        // Process skills
665        if !self.skills.is_empty() {
666            let prompter: Arc<dyn SkillPrompter> = self
667                .skill_prompter
668                .take()
669                .unwrap_or_else(|| Arc::new(LazySkillPrompter::new()));
670
671            let mut skill_refs: Vec<Arc<dyn Skill>> = Vec::new();
672
673            for skill in self.skills {
674                for tool in skill.tools() {
675                    let tool_name = tool.name().to_string();
676                    if self.tool_names.contains(&tool_name) {
677                        return Err(agent_base::AgentError::internal(format!(
678                            "Tool name conflict: `{}` (Skill `{}`)",
679                            tool_name,
680                            skill.name()
681                        )));
682                    }
683                    self.tool_names.insert(tool_name);
684                    ab = ab.register_tool_arc(tool);
685                }
686                skill_refs.push(skill);
687            }
688
689            if !self.disable_skill_prompt_injection {
690                let skill_prompt = prompter.build_prompt(&skill_refs, &self.skill_detail_tool_name);
691                if !skill_prompt.is_empty() {
692                    let new_prompt = match self.system_prompt.take() {
693                        Some(existing) => format!("{}\n\n---\n\n{}", existing, skill_prompt),
694                        None => skill_prompt,
695                    };
696                    self.system_prompt = Some(new_prompt.clone());
697                    ab = ab.system_prompt(new_prompt);
698                }
699            }
700
701            // Use injected factory if available, otherwise skip — prompt-injection
702            // mode uses read_file instead of a dedicated detail tool.
703            if let Some(factory) = self.skill_detail_tool_factory.take() {
704                let detail_tool = factory(skill_refs.clone(), self.skill_detail_tool_name);
705                ab = ab.register_tool_arc(detail_tool);
706            }
707
708            // Create SkillRegistry and populate it for the list-skills tool
709            if let Some(factory) = self.list_skills_tool_factory.take() {
710                let registry = Arc::new(crate::skill::SkillRegistry::new());
711                for skill in &skill_refs {
712                    tokio::task::block_in_place(|| {
713                        tokio::runtime::Handle::current().block_on(async {
714                            registry.register(skill.clone()).await;
715                        })
716                    });
717                }
718                let list_tool = factory(registry);
719                ab = ab.register_tool_arc(list_tool);
720            }
721        }
722
723        // Inject multi-agent prompt
724        #[cfg(feature = "multi_agent")]
725        if ma_config.as_ref().map(|c| c.enabled).unwrap_or(false) {
726            let ma_prompt = build_multi_agent_system_prompt();
727            let new_prompt = match self.system_prompt.take() {
728                Some(existing) => format!("{}\n\n---\n\n{}", existing, ma_prompt),
729                None => ma_prompt,
730            };
731            ab = ab.system_prompt(new_prompt);
732        }
733
734        // Inject inline context compactor if set
735        if let Some(compactor) = self.context_compactor.take() {
736            ab = ab.context_compactor(compactor);
737        }
738
739        let runtime = ab.build()?;
740
741        // Post-build: register multi-agent tools
742        #[cfg(feature = "multi_agent")]
743        if let Some(config) = ma_config
744            && config.enabled
745        {
746            setup_multi_agent(
747                &runtime,
748                config,
749                lang,
750                business_tools,
751                error_recovery,
752                &tool_names,
753                ma_tool_factory,
754            )?;
755        }
756
757        Ok(runtime)
758    }
759
760    /// Like `build_with_skills` but also returns the `MultiAgentRuntime`.
761    #[cfg(all(feature = "skill", feature = "multi_agent"))]
762    fn build_with_skills_ma(
763        mut self,
764    ) -> AgentResult<(AgentRuntime, Option<Arc<MultiAgentRuntime>>)> {
765        if self.inner.get_guard().is_none() {
766            self.inner = self
767                .inner
768                .guard(crate::guard::DefaultGuard::with_llm_client(
769                    crate::guard::DefaultGuardConfig::default(),
770                    self.client.clone(),
771                ));
772        }
773
774        // Inject agent instruction files (e.g., CLAUDE.md) into system prompt
775        if !self.agent_instructions_paths.is_empty()
776            && let Some(instructions) =
777                build_agent_instructions_prompt(&self.agent_instructions_paths)
778        {
779            tracing::info!(
780                paths = ?self.agent_instructions_paths,
781                instructions_len = instructions.len(),
782                "injecting agent instructions into system prompt"
783            );
784            let new_prompt = match self.system_prompt.take() {
785                Some(existing) => format!("{}\n\n---\n\n{}", existing, instructions),
786                None => instructions,
787            };
788            self.system_prompt = Some(new_prompt.clone());
789            self.inner = self.inner.system_prompt(new_prompt);
790        }
791
792        let mut ab = self.inner;
793        let lang = self.language.clone().unwrap_or_default();
794        let business_tools = std::mem::take(&mut self.business_tools);
795        let error_recovery = self.error_recovery.clone();
796        let tool_names = self.tool_names.clone();
797        let ma_config = self.multi_agent_config.clone();
798        let ma_tool_factory = self.multi_agent_tool_factory.take();
799
800        // Process skills
801        if !self.skills.is_empty() {
802            let prompter: Arc<dyn SkillPrompter> = self
803                .skill_prompter
804                .take()
805                .unwrap_or_else(|| Arc::new(LazySkillPrompter::new()));
806
807            let mut skill_refs: Vec<Arc<dyn Skill>> = Vec::new();
808
809            for skill in self.skills {
810                for tool in skill.tools() {
811                    let tool_name = tool.name().to_string();
812                    if self.tool_names.contains(&tool_name) {
813                        return Err(agent_base::AgentError::internal(format!(
814                            "Tool name conflict: `{}` (Skill `{}`)",
815                            tool_name,
816                            skill.name()
817                        )));
818                    }
819                    self.tool_names.insert(tool_name);
820                    ab = ab.register_tool_arc(tool);
821                }
822                skill_refs.push(skill);
823            }
824
825            if !self.disable_skill_prompt_injection {
826                let skill_prompt = prompter.build_prompt(&skill_refs, &self.skill_detail_tool_name);
827                if !skill_prompt.is_empty() {
828                    let new_prompt = match self.system_prompt.take() {
829                        Some(existing) => format!("{}\n\n---\n\n{}", existing, skill_prompt),
830                        None => skill_prompt,
831                    };
832                    self.system_prompt = Some(new_prompt.clone());
833                    ab = ab.system_prompt(new_prompt);
834                }
835            }
836
837            if let Some(factory) = self.skill_detail_tool_factory.take() {
838                let detail_tool = factory(skill_refs.clone(), self.skill_detail_tool_name);
839                ab = ab.register_tool_arc(detail_tool);
840            }
841
842            if let Some(factory) = self.list_skills_tool_factory.take() {
843                let registry = Arc::new(crate::skill::SkillRegistry::new());
844                for skill in &skill_refs {
845                    tokio::task::block_in_place(|| {
846                        tokio::runtime::Handle::current().block_on(async {
847                            registry.register(skill.clone()).await;
848                        })
849                    });
850                }
851                let list_tool = factory(registry);
852                ab = ab.register_tool_arc(list_tool);
853            }
854        }
855
856        // Inject multi-agent prompt
857        if ma_config.as_ref().map(|c| c.enabled).unwrap_or(false) {
858            let ma_prompt = build_multi_agent_system_prompt();
859            let new_prompt = match self.system_prompt.take() {
860                Some(existing) => format!("{}\n\n---\n\n{}", existing, ma_prompt),
861                None => ma_prompt,
862            };
863            ab = ab.system_prompt(new_prompt);
864        }
865
866        if let Some(compactor) = self.context_compactor.take() {
867            ab = ab.context_compactor(compactor);
868        }
869
870        let runtime = ab.build()?;
871
872        let ma_runtime_out = if let Some(config) = ma_config
873            && config.enabled
874        {
875            Some(setup_multi_agent(
876                &runtime,
877                config,
878                lang,
879                business_tools,
880                error_recovery,
881                &tool_names,
882                ma_tool_factory,
883            )?)
884        } else {
885            None
886        };
887
888        Ok((runtime, ma_runtime_out))
889    }
890}
891
892/// Set up the MultiAgentRuntime, event bridge, and register tools on an already-built runtime.
893///
894/// # Safety / Runtime Requirement
895///
896/// This function uses [`tokio::task::block_in_place`] to register tools synchronously.
897/// It **requires** a multi-threaded tokio runtime. Calling it on a
898/// `#[tokio::main]` single-threaded (`current_thread`) runtime will panic.
899///
900/// The phi-agent CLI and all examples use the default multi-threaded runtime,
901/// so this is safe in practice.
902#[cfg(feature = "multi_agent")]
903pub fn setup_multi_agent(
904    runtime: &AgentRuntime,
905    config: MultiAgentConfig,
906    lang: agent_base::Language,
907    business_tools: Vec<Arc<dyn Tool>>,
908    error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
909    existing_tool_names: &HashSet<String>,
910    tool_factory: Option<MultiAgentToolFactory>,
911) -> AgentResult<Arc<MultiAgentRuntime>> {
912    let client = runtime.provider();
913    let cancel_token = runtime.cancel_token();
914    let tool_policy = runtime.tool_policy().cloned();
915    let approval_handler = runtime.approval_handler().cloned();
916
917    let ma_runtime = Arc::new(MultiAgentRuntime::new(
918        config.clone(),
919        client,
920        business_tools,
921        cancel_token,
922        error_recovery,
923        lang,
924        tool_policy,
925        approval_handler,
926    ));
927
928    // Set parent session manager for fork_history support
929    ma_runtime.set_session_manager(Arc::new(runtime.session_manager().clone()));
930
931    // Set up event bridge: child events → parent event bus
932    let (event_tx, mut event_rx) =
933        tokio::sync::mpsc::unbounded_channel::<agent_base::RuntimeEvent>();
934    ma_runtime.set_event_sender(event_tx);
935    let parent_runtime = runtime.clone();
936    tokio::spawn(async move {
937        while let Some(event) = event_rx.recv().await {
938            parent_runtime.emit_event(event);
939        }
940    });
941
942    // Register multi-agent tools if a factory is provided
943    if let Some(factory) = tool_factory {
944        let tools = factory(ma_runtime.clone());
945        let registry = runtime.tools_mut();
946        let mut reg = tokio::task::block_in_place(|| registry.blocking_write());
947        for tool in tools {
948            let tool_name = tool.name().to_string();
949            if !existing_tool_names.contains(&tool_name) {
950                reg.register_arc(tool);
951            }
952        }
953        drop(reg);
954    }
955
956    Ok(ma_runtime)
957}
958
959/// Build the multi-agent system prompt guidance for the main agent.
960#[cfg(feature = "multi_agent")]
961pub fn build_multi_agent_system_prompt() -> String {
962    r#"## Multi-Agent Capabilities
963
964You can spawn sub-agents to work on tasks concurrently. Each sub-agent is an independent assistant with its own tools (repo_map, read_file, list_files, execute_command, etc.) and can reason through problems autonomously.
965
966### Tools
967
968- `spawn_agent`: Create a sub-agent. Use the `task` field to describe what you want done — the sub-agent will figure out the steps. Example: `spawn_agent({task_name: "analyze-codex", task: "analyze the codex project structure and output a structured report"})`.
969- `send_message`: Send follow-up context or tasks to a sub-agent (`trigger: true` starts execution).
970- `list_agents`: List active sub-agents with their assigned task and status — use it to answer "what is agent X doing?".
971- `close_agent`: Close a sub-agent when done.
972
973### How Results Reach You
974
975Sub-agent results are **pushed into your context automatically** when they complete — there is no wait tool. To wait, simply end your turn: you will be woken with the result as a new message. Keep working on other things instead of polling.
976
977### How Sub-Agents Work
978
979Sub-agents are capable — they can explore codebases, read files, run commands, and produce structured analysis. You don't need to plan their steps or specify which tools to use. Just describe the goal in `task`, and the sub-agent handles the rest.
980
981### When to Spawn
982
983- Parallel tasks: analyze multiple codebases, compare implementations, etc.
984- Tasks you want to delegate while you continue other work
985
986### When NOT to Spawn
987
988- Simple lookups (just use read_file or repo_map directly)
989- Tasks that need your current conversation context"#
990        .to_string()
991}
992
993/// Build the memory system prompt guidance.
994///
995/// Tells the LLM how to use the file-based persistent memory system.
996/// Memory is stored as markdown files — the LLM uses `read_file` / `write_file`
997/// to manage them, following the same convention as Claude Code Memory.
998///
999/// This is prompt-injection only — no dedicated memory tools are registered.
1000/// The LLM uses the general-purpose file tools to read/write memory files.
1001pub fn build_memory_system_prompt() -> String {
1002    r#"## Memory
1003
1004You have a persistent file-based memory at `.phi/memory/`. Use `read_file` and `write_file` to manage it — there are no dedicated memory tools.
1005
1006### How Memory Works
1007
1008- `MEMORY.md` is the index — it lists all memories with one-line descriptions. Read it first when you need to recall something.
1009- Each memory is a separate `.md` file with YAML frontmatter:
1010  ```yaml
1011  ---
1012  name: <short-kebab-case-slug>
1013  description: <one-line summary — used to decide relevance during recall>
1014  metadata:
1015    node_type: memory
1016    type: user | feedback | project | reference
1017  ---
1018
1019  <the fact or instruction>
1020  ```
1021- The `description` field is the key for recall — write it so you can tell at a glance whether this memory is relevant to the current task.
1022- Link related memories with `[[memory-name]]` in the body.
1023- `user` type = who the user is (role, expertise, preferences).
1024- `feedback` type = guidance the user has given on how you should work.
1025- `project` type = ongoing work, goals, or constraints.
1026- `reference` type = pointers to external resources (URLs, dashboards, tickets).
1027
1028### When to Use Memory
1029
1030- The user explicitly asks you to remember something ("remember this", "save that")
1031- You learn something important about the user's preferences or workflow
1032- After completing a significant task, save context that would help in future sessions
1033- The user gives you feedback on how to work — save it as `feedback` type
1034
1035### When NOT to Use Memory
1036
1037- For transient information that won't be useful beyond this session
1038- For facts already recorded in the codebase (code structure, git history, config files)
1039- For items that only matter to the current conversation
1040
1041### Pro Tips
1042
1043- When creating your first memory of a new type, you can read template files for format reference (check `.phi/templates/memory/` if available).
1044- Keep the MEMORY.md index concise — it's loaded into context every session.
1045- Before writing a new memory, check if an existing file already covers it — update instead of duplicating.
1046
1047### Workflow
1048
1049**To recall:** read `MEMORY.md` → find relevant entries by description → read the specific `.md` files you need.
1050**To remember:** create a new `.md` file with proper frontmatter → update `MEMORY.md` with a new entry.
1051**To update:** edit the existing `.md` file (don't create a duplicate).
1052**To forget:** delete the `.md` file → remove its entry from `MEMORY.md`."#
1053        .to_string()
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use agent_base::Content;
1060    use agent_base::llm_trait::response::FinishReason;
1061    use agent_base::llm_trait::types::UsageInfo;
1062    use agent_base::llm_trait::{
1063        Capabilities, ChatRequest, ChatResponse, ChatStream, LlmError, LlmProvider, ProviderInfo,
1064    };
1065
1066    // ── Stub LLM provider ──
1067
1068    struct StubProvider;
1069
1070    #[async_trait::async_trait]
1071    impl LlmProvider for StubProvider {
1072        async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
1073            let chunks = vec![
1074                Ok(agent_base::StreamChunk::Text("ok".to_string())),
1075                Ok(agent_base::StreamChunk::Stop {
1076                    finish_reason: Some("stop".to_string()),
1077                }),
1078            ];
1079            Ok(ChatStream::new(Box::pin(futures_util::stream::iter(
1080                chunks,
1081            ))))
1082        }
1083
1084        async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
1085            Ok(ChatResponse {
1086                content: "ok".to_string(),
1087                tool_calls: vec![],
1088                usage: UsageInfo::default(),
1089                finish_reason: FinishReason::Stop,
1090                raw: None,
1091                reasoning_content: None,
1092                thinking_signature: None,
1093            })
1094        }
1095
1096        fn capabilities(&self) -> Capabilities {
1097            Capabilities {
1098                supports_streaming: true,
1099                supports_tools: true,
1100                ..Default::default()
1101            }
1102        }
1103
1104        fn info(&self) -> ProviderInfo {
1105            ProviderInfo {
1106                name: "stub".to_string(),
1107                model: "stub-model".to_string(),
1108                version: None,
1109            }
1110        }
1111    }
1112
1113    fn make_client() -> Arc<dyn LlmProvider> {
1114        Arc::new(StubProvider)
1115    }
1116
1117    // ── setup_multi_agent tests ──
1118
1119    #[cfg(feature = "multi_agent")]
1120    #[tokio::test(flavor = "multi_thread")]
1121    async fn test_setup_multi_agent_without_factory_registers_no_tools() {
1122        let client = make_client();
1123        let runtime = agent_base::AgentBuilder::new(client.clone())
1124            .build()
1125            .unwrap();
1126        let config = MultiAgentConfig::enabled();
1127
1128        let result = setup_multi_agent(
1129            &runtime,
1130            config,
1131            agent_base::Language::En,
1132            vec![],
1133            None,
1134            &HashSet::new(),
1135            None, // no factory
1136        );
1137        assert!(result.is_ok());
1138        let ma_runtime = result.unwrap();
1139        // Verify no tools were registered (the 5 multi-agent tools are absent)
1140        let agents = ma_runtime.list_agents();
1141        assert!(agents.is_empty());
1142    }
1143
1144    #[cfg(feature = "multi_agent")]
1145    #[tokio::test(flavor = "multi_thread")]
1146    async fn test_setup_multi_agent_with_factory_registers_tools() {
1147        let client = make_client();
1148        let runtime = agent_base::AgentBuilder::new(client.clone())
1149            .build()
1150            .unwrap();
1151        let config = MultiAgentConfig::enabled();
1152
1153        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
1154            // Minimal factory returning a single fake tool
1155            struct FakeTool;
1156            #[async_trait::async_trait]
1157            impl Tool for FakeTool {
1158                fn name(&self) -> &'static str {
1159                    "fake_tool"
1160                }
1161                fn description(&self) -> &'static str {
1162                    ""
1163                }
1164                fn schema(&self) -> serde_json::Value {
1165                    serde_json::json!({})
1166                }
1167                async fn call(
1168                    &self,
1169                    _args: &serde_json::Value,
1170                    _ctx: &agent_base::ToolContext,
1171                ) -> AgentResult<Vec<Content>> {
1172                    Ok(vec![Content::text("ok")])
1173                }
1174            }
1175            vec![Arc::new(FakeTool)]
1176        });
1177
1178        let result = setup_multi_agent(
1179            &runtime,
1180            config,
1181            agent_base::Language::En,
1182            vec![],
1183            None,
1184            &HashSet::new(),
1185            Some(factory),
1186        );
1187        assert!(result.is_ok());
1188
1189        // Check the tool was registered on the runtime
1190        let tools: Vec<String> = tokio::task::block_in_place(|| {
1191            let tools = runtime.tools_mut();
1192            let guard = tools.blocking_read();
1193            guard.metadatas().into_iter().map(|m| m.name).collect()
1194        });
1195        assert!(tools.contains(&"fake_tool".to_string()));
1196    }
1197
1198    #[cfg(feature = "multi_agent")]
1199    #[tokio::test(flavor = "multi_thread")]
1200    async fn test_setup_multi_agent_skips_duplicate_tool_names() {
1201        let client = make_client();
1202        let runtime = agent_base::AgentBuilder::new(client.clone())
1203            .build()
1204            .unwrap();
1205
1206        // Pre-register a tool with a conflicting name
1207        struct DupTool;
1208        #[async_trait::async_trait]
1209        impl Tool for DupTool {
1210            fn name(&self) -> &'static str {
1211                "dup_tool"
1212            }
1213            fn description(&self) -> &'static str {
1214                ""
1215            }
1216            fn schema(&self) -> serde_json::Value {
1217                serde_json::json!({})
1218            }
1219            async fn call(
1220                &self,
1221                _args: &serde_json::Value,
1222                _ctx: &agent_base::ToolContext,
1223            ) -> AgentResult<Vec<Content>> {
1224                Ok(vec![Content::text("ok")])
1225            }
1226        }
1227        {
1228            let tools = runtime.tools_mut();
1229            let mut reg = tokio::task::block_in_place(|| tools.blocking_write());
1230            reg.register(DupTool);
1231        }
1232
1233        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
1234            struct FakeTool;
1235            #[async_trait::async_trait]
1236            impl Tool for FakeTool {
1237                fn name(&self) -> &'static str {
1238                    "dup_tool"
1239                }
1240                fn description(&self) -> &'static str {
1241                    ""
1242                }
1243                fn schema(&self) -> serde_json::Value {
1244                    serde_json::json!({})
1245                }
1246                async fn call(
1247                    &self,
1248                    _args: &serde_json::Value,
1249                    _ctx: &agent_base::ToolContext,
1250                ) -> AgentResult<Vec<Content>> {
1251                    Ok(vec![Content::text("ok")])
1252                }
1253            }
1254            vec![Arc::new(FakeTool)]
1255        });
1256
1257        let mut existing = HashSet::new();
1258        existing.insert("dup_tool".to_string());
1259
1260        let result = setup_multi_agent(
1261            &runtime,
1262            MultiAgentConfig::enabled(),
1263            agent_base::Language::En,
1264            vec![],
1265            None,
1266            &existing,
1267            Some(factory),
1268        );
1269        assert!(result.is_ok());
1270        // dup_tool should NOT have been registered twice
1271        let tools = tokio::task::block_in_place(|| {
1272            let tools = runtime.tools_mut();
1273            let guard = tools.blocking_read();
1274            guard
1275                .metadatas()
1276                .into_iter()
1277                .map(|m| m.name)
1278                .collect::<Vec<String>>()
1279        });
1280        let count = tools.iter().filter(|n| n.as_str() == "dup_tool").count();
1281        assert_eq!(count, 1);
1282    }
1283
1284    // ── AgentBuilder factory methods ──
1285
1286    #[cfg(feature = "multi_agent")]
1287    #[tokio::test(flavor = "multi_thread")]
1288    async fn test_builder_with_multi_agent_without_factory_builds_ok() {
1289        let client = make_client();
1290        let runtime = AgentBuilder::new(client)
1291            .with_multi_agent(MultiAgentConfig::enabled())
1292            .build()
1293            .unwrap();
1294        // Should succeed even without a factory (no tools registered)
1295        let tools = tokio::task::block_in_place(|| {
1296            let tools = runtime.tools_mut();
1297            let guard = tools.blocking_read();
1298            guard
1299                .metadatas()
1300                .into_iter()
1301                .map(|m| m.name)
1302                .collect::<Vec<String>>()
1303        });
1304        // No multi-agent tools registered
1305        assert!(!tools.contains(&"spawn_agent".to_string()));
1306    }
1307
1308    #[cfg(feature = "multi_agent")]
1309    #[tokio::test(flavor = "multi_thread")]
1310    async fn test_builder_with_factory_registers_tools() {
1311        let client = make_client();
1312        // Create a simple factory that registers one recognizable tool
1313        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
1314            struct TestTool;
1315            #[async_trait::async_trait]
1316            impl Tool for TestTool {
1317                fn name(&self) -> &'static str {
1318                    "factory_test_tool"
1319                }
1320                fn description(&self) -> &'static str {
1321                    ""
1322                }
1323                fn schema(&self) -> serde_json::Value {
1324                    serde_json::json!({})
1325                }
1326                async fn call(
1327                    &self,
1328                    _args: &serde_json::Value,
1329                    _ctx: &agent_base::ToolContext,
1330                ) -> AgentResult<Vec<Content>> {
1331                    Ok(vec![Content::text("ok")])
1332                }
1333            }
1334            vec![Arc::new(TestTool)]
1335        });
1336
1337        let runtime = AgentBuilder::new(client)
1338            .with_multi_agent(MultiAgentConfig::enabled())
1339            .with_multi_agent_tool_factory(factory)
1340            .build()
1341            .unwrap();
1342
1343        let tools = tokio::task::block_in_place(|| {
1344            let tools = runtime.tools_mut();
1345            let guard = tools.blocking_read();
1346            guard
1347                .metadatas()
1348                .into_iter()
1349                .map(|m| m.name)
1350                .collect::<Vec<String>>()
1351        });
1352        assert!(tools.contains(&"factory_test_tool".to_string()));
1353    }
1354
1355    #[cfg(feature = "multi_agent")]
1356    #[test]
1357    fn test_builder_disabled_multi_agent_skips_factory() {
1358        let client = make_client();
1359        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
1360            panic!("factory should not be called when multi-agent is not configured");
1361        });
1362
1363        let runtime = AgentBuilder::new(client)
1364            .with_multi_agent_tool_factory(factory)
1365            // Don't enable multi-agent — default (None) means disabled
1366            .build()
1367            .unwrap();
1368
1369        let tools = tokio::task::block_in_place(|| {
1370            let tools = runtime.tools_mut();
1371            let guard = tools.blocking_read();
1372            guard
1373                .metadatas()
1374                .into_iter()
1375                .map(|m| m.name)
1376                .collect::<Vec<String>>()
1377        });
1378        assert!(!tools.contains(&"spawn_agent".to_string()));
1379    }
1380
1381    // ── build_multi_agent_system_prompt ──
1382
1383    #[cfg(feature = "multi_agent")]
1384    #[test]
1385    fn test_system_prompt_contains_tool_names() {
1386        let prompt = build_multi_agent_system_prompt();
1387        assert!(prompt.contains("spawn_agent"));
1388        assert!(prompt.contains("send_message"));
1389        assert!(prompt.contains("trigger"));
1390        assert!(prompt.contains("list_agents"));
1391        assert!(prompt.contains("close_agent"));
1392        // followup_task is gone (§8.3) — guidance must not advertise a tool
1393        // the factory no longer registers.
1394        assert!(!prompt.contains("followup_task"));
1395        // wait_agent is gone too — results are pushed, the parent waits by
1396        // ending its turn.
1397        assert!(!prompt.contains("wait_agent"));
1398        assert!(prompt.contains("pushed into your context automatically"));
1399    }
1400
1401    #[cfg(feature = "multi_agent")]
1402    #[test]
1403    fn test_system_prompt_contains_guidance() {
1404        let prompt = build_multi_agent_system_prompt();
1405        assert!(prompt.contains("When to Spawn"));
1406        assert!(prompt.contains("When NOT to Spawn"));
1407        assert!(prompt.contains("How Results Reach You"));
1408    }
1409
1410    // ── without_multi_agent ──
1411
1412    #[cfg(feature = "multi_agent")]
1413    #[tokio::test(flavor = "multi_thread")]
1414    async fn test_without_multi_agent_clears_config_and_factory() {
1415        let client = make_client();
1416
1417        // Set up a factory that would panic if called — without_multi_agent should prevent it
1418        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
1419            panic!("factory should not be called when multi-agent is cleared");
1420        });
1421
1422        let runtime = AgentBuilder::new(client)
1423            .with_multi_agent(MultiAgentConfig::enabled())
1424            .with_multi_agent_tool_factory(factory)
1425            .without_multi_agent() // clear both
1426            .build()
1427            .unwrap();
1428
1429        let tools = tokio::task::block_in_place(|| {
1430            let tools = runtime.tools_mut();
1431            let guard = tools.blocking_read();
1432            guard
1433                .metadatas()
1434                .into_iter()
1435                .map(|m| m.name)
1436                .collect::<Vec<String>>()
1437        });
1438        assert!(!tools.contains(&"spawn_agent".to_string()));
1439    }
1440
1441    // ── apply_if ──
1442
1443    #[test]
1444    fn test_apply_if_some_applies_transformation() {
1445        let client = make_client();
1446        let builder = AgentBuilder::new(client)
1447            .apply_if(Some("custom prompt"), |b, prompt| b.system_prompt(prompt));
1448        // system_prompt is stored in self.system_prompt; verify it was set
1449        assert!(builder.system_prompt.unwrap().contains("custom prompt"));
1450    }
1451
1452    #[test]
1453    fn test_apply_if_none_passes_through() {
1454        let client = make_client();
1455        let builder = AgentBuilder::new(client).apply_if(None as Option<&str>, |_b, _prompt| {
1456            panic!("should not be called when value is None");
1457        });
1458        assert!(builder.system_prompt.is_none());
1459    }
1460
1461    // ── build_memory_system_prompt ──
1462
1463    #[test]
1464    fn test_build_memory_system_prompt_non_empty() {
1465        let prompt = build_memory_system_prompt();
1466        assert!(!prompt.is_empty());
1467        assert!(prompt.contains("Memory"));
1468        assert!(prompt.contains("MEMORY.md"));
1469        assert!(prompt.contains("read_file"));
1470        assert!(prompt.contains("write_file"));
1471    }
1472
1473    // ── Named tool for register_tool / skill tests ──
1474
1475    struct NamedTool(&'static str);
1476
1477    #[async_trait::async_trait]
1478    impl Tool for NamedTool {
1479        fn name(&self) -> &'static str {
1480            self.0
1481        }
1482
1483        fn description(&self) -> &'static str {
1484            ""
1485        }
1486
1487        fn schema(&self) -> serde_json::Value {
1488            serde_json::json!({})
1489        }
1490
1491        async fn call(
1492            &self,
1493            _args: &serde_json::Value,
1494            _ctx: &agent_base::ToolContext,
1495        ) -> AgentResult<Vec<Content>> {
1496            Ok(vec![Content::text("ok")])
1497        }
1498    }
1499
1500    fn runtime_tool_names(runtime: &AgentRuntime) -> Vec<String> {
1501        tokio::task::block_in_place(|| {
1502            let tools = runtime.tools_mut();
1503            let guard = tools.blocking_read();
1504            guard.metadatas().into_iter().map(|m| m.name).collect()
1505        })
1506    }
1507
1508    #[tokio::test(flavor = "multi_thread")]
1509    async fn test_builder_scalar_passthrough_methods() {
1510        let client = make_client();
1511        let runtime = AgentBuilder::new(client)
1512            .enable_thought(true)
1513            .reasoning(agent_base::ReasoningConfig::default())
1514            .enable_thinking(false)
1515            .thinking_budget(1000)
1516            .tool_timeout(5000)
1517            .max_tool_output_chars(4000)
1518            .max_sessions(16)
1519            .max_turns_per_session(20)
1520            .execution_max_turns(10)
1521            .max_message_tokens(8000)
1522            .context_window(64_000)
1523            .context_window_manager(agent_base::ContextWindowManager::new(64_000))
1524            .response_format(agent_base::ResponseFormat::JsonObject)
1525            .llm_retry(agent_base::RetryConfig::default())
1526            .tool_error_retry_prompt("please retry")
1527            .language(agent_base::Language::En)
1528            .event_bus_capacity(256)
1529            .build()
1530            .unwrap();
1531
1532        assert!(runtime.provider().capabilities().supports_streaming);
1533    }
1534
1535    #[tokio::test(flavor = "multi_thread")]
1536    async fn test_register_tool_variants() {
1537        let client = make_client();
1538        let runtime = AgentBuilder::new(client)
1539            .register_tool(NamedTool("tool_by_value"))
1540            .register_tool_arc(Arc::new(NamedTool("tool_by_arc")))
1541            .build()
1542            .unwrap();
1543
1544        let names = runtime_tool_names(&runtime);
1545        assert!(names.contains(&"tool_by_value".to_string()));
1546        assert!(names.contains(&"tool_by_arc".to_string()));
1547    }
1548
1549    #[cfg(feature = "skill")]
1550    mod skill_tests {
1551        use super::*;
1552        use crate::skill::Skill;
1553
1554        struct TestSkill;
1555
1556        impl Skill for TestSkill {
1557            fn name(&self) -> &'static str {
1558                "test_skill"
1559            }
1560
1561            fn brief_description(&self) -> String {
1562                "a test skill".to_string()
1563            }
1564
1565            fn detailed_description(&self) -> String {
1566                "detailed test skill".to_string()
1567            }
1568
1569            fn tools(&self) -> Vec<Arc<dyn Tool>> {
1570                vec![]
1571            }
1572        }
1573
1574        struct ToolSkill;
1575
1576        impl Skill for ToolSkill {
1577            fn name(&self) -> &'static str {
1578                "tool_skill"
1579            }
1580
1581            fn brief_description(&self) -> String {
1582                "skill with a tool".to_string()
1583            }
1584
1585            fn detailed_description(&self) -> String {
1586                "skill that provides a tool".to_string()
1587            }
1588
1589            fn tools(&self) -> Vec<Arc<dyn Tool>> {
1590                vec![Arc::new(NamedTool("skill_provided_tool"))]
1591            }
1592        }
1593
1594        #[tokio::test(flavor = "multi_thread")]
1595        async fn test_register_skill_builds_ok() {
1596            let client = make_client();
1597            let runtime = AgentBuilder::new(client)
1598                .register_skill(TestSkill)
1599                .build()
1600                .unwrap();
1601            // Prompt injection is applied during build; no tools provided.
1602            assert!(runtime_tool_names(&runtime).is_empty());
1603        }
1604
1605        #[tokio::test(flavor = "multi_thread")]
1606        async fn test_register_skill_with_tool_registers_tool() {
1607            let client = make_client();
1608            let runtime = AgentBuilder::new(client)
1609                .register_skill(ToolSkill)
1610                .build()
1611                .unwrap();
1612            assert!(runtime_tool_names(&runtime).contains(&"skill_provided_tool".to_string()));
1613        }
1614
1615        #[tokio::test(flavor = "multi_thread")]
1616        async fn test_register_skill_tool_name_conflict() {
1617            let client = make_client();
1618            let result = AgentBuilder::new(client)
1619                .register_tool(NamedTool("skill_provided_tool"))
1620                .register_skill(ToolSkill)
1621                .build();
1622            let err = result.err().unwrap();
1623            assert!(format!("{err}").contains("Tool name conflict"));
1624        }
1625
1626        #[tokio::test(flavor = "multi_thread")]
1627        async fn test_skill_detail_tool_factory_registers_tool() {
1628            let client = make_client();
1629            let factory: SkillDetailToolFactory = Arc::new(|_skills, name| {
1630                assert_eq!(name, "get_skill_detail");
1631                Arc::new(NamedTool("detail_tool"))
1632            });
1633            let runtime = AgentBuilder::new(client)
1634                .register_skill(TestSkill)
1635                .with_skill_detail_tool_factory(factory)
1636                .build()
1637                .unwrap();
1638            assert!(runtime_tool_names(&runtime).contains(&"detail_tool".to_string()));
1639        }
1640
1641        #[tokio::test(flavor = "multi_thread")]
1642        async fn test_list_skills_tool_factory_registers_tool() {
1643            let client = make_client();
1644            let factory: ListSkillsToolFactory =
1645                Arc::new(|_registry| Arc::new(NamedTool("list_skills_tool")));
1646            let runtime = AgentBuilder::new(client)
1647                .register_skill(TestSkill)
1648                .with_list_skills_tool_factory(factory)
1649                .build()
1650                .unwrap();
1651            assert!(runtime_tool_names(&runtime).contains(&"list_skills_tool".to_string()));
1652        }
1653
1654        #[tokio::test(flavor = "multi_thread")]
1655        async fn test_disable_skill_prompt_injection_builds() {
1656            let client = make_client();
1657            let runtime = AgentBuilder::new(client)
1658                .register_skill(ToolSkill)
1659                .disable_skill_prompt_injection()
1660                .build()
1661                .unwrap();
1662            // Tool still registered; prompt injection skipped.
1663            assert!(runtime_tool_names(&runtime).contains(&"skill_provided_tool".to_string()));
1664        }
1665    }
1666
1667    // ── Agent instructions tests ──
1668
1669    #[test]
1670    fn test_build_agent_instructions_prompt_reads_files() {
1671        let tmp = tempfile::tempdir().unwrap();
1672        let file1 = tmp.path().join("CLAUDE.md");
1673        let file2 = tmp.path().join("INSTRUCTIONS.md");
1674        std::fs::write(&file1, "# User instructions\nBe helpful.").unwrap();
1675        std::fs::write(&file2, "# Project instructions\nFollow conventions.").unwrap();
1676
1677        let prompt = build_agent_instructions_prompt(&[file1, file2]);
1678        assert!(prompt.is_some());
1679        let prompt = prompt.unwrap();
1680        // Higher priority (later entries) appear first
1681        assert!(prompt.starts_with("# Project instructions"));
1682        assert!(prompt.contains("# User instructions"));
1683        assert!(prompt.contains("Be helpful."));
1684        assert!(prompt.contains("Follow conventions."));
1685    }
1686
1687    #[test]
1688    fn test_build_agent_instructions_prompt_skips_missing_files() {
1689        let prompt = build_agent_instructions_prompt(&[
1690            PathBuf::from("/nonexistent/CLAUDE.md"),
1691            PathBuf::from("/nonexistent/INSTRUCTIONS.md"),
1692        ]);
1693        assert!(prompt.is_none());
1694    }
1695
1696    #[test]
1697    fn test_build_agent_instructions_prompt_skips_empty_files() {
1698        let tmp = tempfile::tempdir().unwrap();
1699        let file = tmp.path().join("empty.md");
1700        std::fs::write(&file, "   \n  ").unwrap();
1701
1702        let prompt = build_agent_instructions_prompt(&[file]);
1703        assert!(prompt.is_none());
1704    }
1705}