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