Skip to main content

agent_works/
builder.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use agent_base::{AgentResult, AgentRuntime, StreamClient, Tool};
5
6use crate::multi_agent::{MultiAgentConfig, MultiAgentRuntime};
7
8#[cfg(feature = "skill")]
9use crate::skill::{LazySkillPrompter, Skill, SkillPrompter};
10
11/// Factory type for creating multi-agent tools from a MultiAgentRuntime.
12pub type MultiAgentToolFactory =
13    Arc<dyn Fn(Arc<MultiAgentRuntime>) -> Vec<Arc<dyn Tool>> + Send + Sync>;
14
15/// Factory type for creating a skill detail tool from skills and a tool name.
16#[cfg(feature = "skill")]
17pub type SkillDetailToolFactory =
18    Arc<dyn Fn(Vec<Arc<dyn Skill>>, String) -> Arc<dyn Tool> + Send + Sync>;
19
20/// Factory type for creating a list-skills tool from a SkillRegistry.
21#[cfg(feature = "skill")]
22pub type ListSkillsToolFactory =
23    Arc<dyn Fn(Arc<crate::skill::SkillRegistry>) -> Arc<dyn Tool> + Send + Sync>;
24
25pub struct AgentBuilder {
26    inner: agent_base::AgentBuilder,
27    system_prompt: Option<String>,
28    tool_names: HashSet<String>,
29    /// Business tools to pass to child agents (all registered tools).
30    business_tools: Vec<Arc<dyn Tool>>,
31    /// Multi-agent configuration (None = disabled).
32    multi_agent_config: Option<MultiAgentConfig>,
33    /// Factory to create multi-agent tools (injected by phi-kernel-tools).
34    multi_agent_tool_factory: Option<MultiAgentToolFactory>,
35    /// Error recovery (stored for multi-agent child inheritance).
36    error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
37    /// Language preference.
38    language: Option<agent_base::Language>,
39    #[cfg(feature = "skill")]
40    skills: Vec<Arc<dyn Skill>>,
41    #[cfg(feature = "skill")]
42    skill_prompter: Option<Arc<dyn SkillPrompter>>,
43    #[cfg(feature = "skill")]
44    skill_detail_tool_name: String,
45    /// Optional: inject a custom skill-detail tool (old tool-based mode).
46    /// In default prompt-injection mode, the LLM reads `SKILL.md` via
47    /// `read_file` — no dedicated detail tool is needed.
48    #[cfg(feature = "skill")]
49    skill_detail_tool_factory: Option<SkillDetailToolFactory>,
50    #[cfg(feature = "skill")]
51    list_skills_tool_factory: Option<ListSkillsToolFactory>,
52    #[cfg(feature = "skill")]
53    disable_skill_prompt_injection: bool,
54}
55
56impl AgentBuilder {
57    pub fn new(client: Arc<dyn StreamClient>) -> Self {
58        Self {
59            inner: agent_base::AgentBuilder::new(client),
60            system_prompt: None,
61            tool_names: HashSet::new(),
62            business_tools: Vec::new(),
63            multi_agent_config: None,
64            multi_agent_tool_factory: None,
65            error_recovery: None,
66            language: None,
67            #[cfg(feature = "skill")]
68            skills: Vec::new(),
69            #[cfg(feature = "skill")]
70            skill_prompter: None,
71            #[cfg(feature = "skill")]
72            skill_detail_tool_name: "get_skill_detail".to_string(),
73            #[cfg(feature = "skill")]
74            skill_detail_tool_factory: None,
75            #[cfg(feature = "skill")]
76            list_skills_tool_factory: None,
77            #[cfg(feature = "skill")]
78            disable_skill_prompt_injection: false,
79        }
80    }
81
82    /// Enable multi-agent support with the given configuration.
83    ///
84    /// Also sets the tool factory to create the 6 multi-agent tools.
85    /// Callers should use `phi_kernel_tools::multi_agent::create_all_tools` as the factory.
86    pub fn with_multi_agent(mut self, config: MultiAgentConfig) -> Self {
87        self.multi_agent_config = Some(config);
88        self
89    }
90
91    /// Disable multi-agent support.
92    ///
93    /// Removes any previously set multi-agent configuration. No multi-agent tools
94    /// will be registered and the system prompt will not mention multi-agent capabilities.
95    pub fn without_multi_agent(mut self) -> Self {
96        self.multi_agent_config = None;
97        self.multi_agent_tool_factory = None;
98        self
99    }
100
101    /// Set a custom factory for creating multi-agent tools.
102    ///
103    /// The factory receives the `MultiAgentRuntime` and returns the tools to register.
104    /// If not set but multi-agent is enabled, no tools are registered (caller must
105    /// set this for multi-agent to work).
106    pub fn with_multi_agent_tool_factory(mut self, factory: MultiAgentToolFactory) -> Self {
107        self.multi_agent_tool_factory = Some(factory);
108        self
109    }
110
111    /// Set a custom factory for creating the skill detail tool.
112    ///
113    /// The factory receives the skill list and tool name, and returns the tool.
114    /// If not set but skills are registered, no detail tool is added.
115    #[cfg(feature = "skill")]
116    pub fn with_skill_detail_tool_factory(mut self, factory: SkillDetailToolFactory) -> Self {
117        self.skill_detail_tool_factory = Some(factory);
118        self
119    }
120
121    /// Set a custom factory for creating the list-skills tool.
122    ///
123    /// The factory receives the SkillRegistry and returns the tool.
124    #[cfg(feature = "skill")]
125    pub fn with_list_skills_tool_factory(mut self, factory: ListSkillsToolFactory) -> Self {
126        self.list_skills_tool_factory = Some(factory);
127        self
128    }
129
130    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
131        let prompt = prompt.into();
132        self.inner = self.inner.system_prompt(prompt.clone());
133        self.system_prompt = Some(prompt);
134        self
135    }
136
137    pub fn enable_thought(self, enable: bool) -> Self {
138        Self {
139            inner: self.inner.enable_thought(enable),
140            ..self
141        }
142    }
143
144    pub fn reasoning(self, config: agent_base::ReasoningConfig) -> Self {
145        Self {
146            inner: self.inner.reasoning(config),
147            ..self
148        }
149    }
150
151    pub fn enable_thinking(self, enable: bool) -> Self {
152        Self {
153            inner: self.inner.enable_thinking(enable),
154            ..self
155        }
156    }
157
158    pub fn thinking_budget(self, budget: u64) -> Self {
159        Self {
160            inner: self.inner.thinking_budget(budget),
161            ..self
162        }
163    }
164
165    pub fn tool_timeout(self, timeout_ms: u64) -> Self {
166        Self {
167            inner: self.inner.tool_timeout(timeout_ms),
168            ..self
169        }
170    }
171
172    pub fn max_tool_output_chars(self, max_chars: usize) -> Self {
173        Self {
174            inner: self.inner.max_tool_output_chars(max_chars),
175            ..self
176        }
177    }
178
179    pub fn max_sessions(self, max: usize) -> Self {
180        Self {
181            inner: self.inner.max_sessions(max),
182            ..self
183        }
184    }
185
186    pub fn max_turns_per_session(self, max: usize) -> Self {
187        Self {
188            inner: self.inner.max_turns_per_session(max),
189            ..self
190        }
191    }
192
193    pub fn execution_max_turns(self, max: u32) -> Self {
194        Self {
195            inner: self.inner.execution_max_turns(max),
196            ..self
197        }
198    }
199
200    pub fn max_message_tokens(self, max: usize) -> Self {
201        Self {
202            inner: self.inner.max_message_tokens(max),
203            ..self
204        }
205    }
206
207    pub fn register_tool(mut self, tool: impl Tool + 'static) -> Self {
208        let tool_arc: Arc<dyn Tool> = Arc::new(tool);
209        self.tool_names.insert(tool_arc.name().to_string());
210        self.business_tools.push(tool_arc.clone());
211        self.inner = self.inner.register_tool_arc(tool_arc);
212        self
213    }
214
215    pub fn register_tool_arc(mut self, tool: Arc<dyn Tool>) -> Self {
216        self.tool_names.insert(tool.name().to_string());
217        self.business_tools.push(tool.clone());
218        self.inner = self.inner.register_tool_arc(tool);
219        self
220    }
221
222    pub fn approval_handler(self, handler: Arc<dyn agent_base::ApprovalHandler>) -> Self {
223        Self {
224            inner: self.inner.approval_handler(handler),
225            ..self
226        }
227    }
228
229    pub fn tool_policy(self, policy: Arc<dyn agent_base::ToolPolicy>) -> Self {
230        Self {
231            inner: self.inner.tool_policy(policy),
232            ..self
233        }
234    }
235
236    pub fn middleware(self, mw: impl agent_base::Middleware + 'static) -> Self {
237        Self {
238            inner: self.inner.middleware(mw),
239            ..self
240        }
241    }
242
243    pub fn context_window(self, max_tokens: usize) -> Self {
244        Self {
245            inner: self.inner.context_window(max_tokens),
246            ..self
247        }
248    }
249
250    pub fn context_window_manager(self, manager: agent_base::ContextWindowManager) -> Self {
251        Self {
252            inner: self.inner.context_window_manager(manager),
253            ..self
254        }
255    }
256
257    pub fn response_format(self, format: agent_base::ResponseFormat) -> Self {
258        Self {
259            inner: self.inner.response_format(format),
260            ..self
261        }
262    }
263
264    pub fn llm_retry(self, retry: agent_base::RetryConfig) -> Self {
265        Self {
266            inner: self.inner.llm_retry(retry),
267            ..self
268        }
269    }
270
271    pub fn session_store(self, store: Arc<dyn agent_base::SessionStore>) -> Self {
272        Self {
273            inner: self.inner.session_store(store),
274            ..self
275        }
276    }
277
278    pub fn error_recovery(mut self, recovery: Arc<dyn agent_base::ToolErrorRecovery>) -> Self {
279        self.error_recovery = Some(recovery.clone());
280        self.inner = self.inner.error_recovery(recovery);
281        self
282    }
283
284    pub fn tool_error_retry_prompt(self, prompt: impl Into<String>) -> Self {
285        Self {
286            inner: self.inner.tool_error_retry_prompt(prompt),
287            ..self
288        }
289    }
290
291    pub fn language(mut self, language: agent_base::Language) -> Self {
292        self.language = Some(language.clone());
293        self.inner = self.inner.language(language);
294        self
295    }
296
297    pub fn event_bus_capacity(self, capacity: usize) -> Self {
298        Self {
299            inner: self.inner.event_bus_capacity(capacity),
300            ..self
301        }
302    }
303
304    pub fn session_id_generator(
305        self,
306        generator: Arc<dyn agent_base::types::SessionIdGenerator>,
307    ) -> Self {
308        Self {
309            inner: self.inner.session_id_generator(generator),
310            ..self
311        }
312    }
313
314    /// Conditionally apply a transformation when `value` is `Some`.
315    ///
316    /// This is a convenience for option-chaining builder patterns:
317    ///
318    /// ```ignore
319    /// builder.apply_if(args.thinking_budget, |b, budget| b.thinking_budget(budget))
320    /// ```
321    pub fn apply_if<T>(self, value: Option<T>, f: impl FnOnce(Self, T) -> Self) -> Self {
322        match value {
323            Some(v) => f(self, v),
324            None => self,
325        }
326    }
327
328    #[cfg(feature = "skill")]
329    pub fn register_skill(mut self, skill: impl Skill + 'static) -> Self {
330        self.skills.push(Arc::new(skill));
331        self
332    }
333
334    #[cfg(feature = "skill")]
335    pub fn register_skills(mut self, skills: Vec<Arc<dyn Skill>>) -> Self {
336        self.skills.extend(skills);
337        self
338    }
339
340    #[cfg(feature = "skill")]
341    pub fn skill_prompter(mut self, prompter: Arc<dyn SkillPrompter>) -> Self {
342        self.skill_prompter = Some(prompter);
343        self
344    }
345
346    #[cfg(feature = "skill")]
347    pub fn disable_skill_prompt_injection(mut self) -> Self {
348        self.disable_skill_prompt_injection = true;
349        self
350    }
351
352    #[cfg(feature = "skill")]
353    pub fn skill_detail_tool_name(mut self, name: impl Into<String>) -> Self {
354        self.skill_detail_tool_name = name.into();
355        self
356    }
357
358    // ── Build ──
359
360    pub fn build(self) -> AgentResult<AgentRuntime> {
361        #[cfg(feature = "skill")]
362        {
363            self.build_with_skills()
364        }
365        #[cfg(not(feature = "skill"))]
366        {
367            self.build_inner()
368        }
369    }
370
371    #[allow(dead_code)]
372    fn build_inner(mut self) -> AgentResult<AgentRuntime> {
373        let lang = self.language.clone().unwrap_or_default();
374        let ma_config = self.multi_agent_config.clone();
375        let ma_tool_factory = self.multi_agent_tool_factory.take();
376        let business_tools = std::mem::take(&mut self.business_tools);
377        let error_recovery = self.error_recovery.clone();
378        let tool_names = self.tool_names.clone();
379
380        // Inject multi-agent prompt before build
381        if ma_config.as_ref().map(|c| c.enabled).unwrap_or(false) {
382            let ma_prompt = build_multi_agent_system_prompt();
383            let new_prompt = match self.system_prompt.take() {
384                Some(existing) => format!("{}\n\n---\n\n{}", existing, ma_prompt),
385                None => ma_prompt,
386            };
387            self.inner = self.inner.system_prompt(new_prompt);
388        }
389
390        let runtime = self.inner.build()?;
391
392        // Post-build: register multi-agent tools if enabled and factory is set
393        if let Some(config) = ma_config
394            && config.enabled
395        {
396            setup_multi_agent(
397                &runtime,
398                config,
399                lang,
400                business_tools,
401                error_recovery,
402                &tool_names,
403                ma_tool_factory,
404            )?;
405        }
406
407        Ok(runtime)
408    }
409
410    /// Build the runtime with skill support.
411    ///
412    /// # Runtime requirement
413    ///
414    /// This method uses [`tokio::task::block_in_place`] to populate the skill
415    /// registry from a synchronous context. It **requires** a multi-threaded
416    /// tokio runtime. Calling it on a `#[tokio::main]` single-threaded
417    /// (`current_thread`) runtime will panic.
418    ///
419    /// The phi-agent CLI and all examples use the default multi-threaded runtime,
420    /// so this is safe in practice.
421    #[cfg(feature = "skill")]
422    fn build_with_skills(mut self) -> AgentResult<AgentRuntime> {
423        let mut ab = self.inner;
424        let lang = self.language.clone().unwrap_or_default();
425        let ma_config = self.multi_agent_config.clone();
426        let ma_tool_factory = self.multi_agent_tool_factory.take();
427        let business_tools = std::mem::take(&mut self.business_tools);
428        let error_recovery = self.error_recovery.clone();
429        let tool_names = self.tool_names.clone();
430
431        // Process skills
432        if !self.skills.is_empty() {
433            let prompter: Arc<dyn SkillPrompter> = self
434                .skill_prompter
435                .take()
436                .unwrap_or_else(|| Arc::new(LazySkillPrompter::new()));
437
438            let mut skill_refs: Vec<Arc<dyn Skill>> = Vec::new();
439
440            for skill in self.skills {
441                for tool in skill.tools() {
442                    let tool_name = tool.name().to_string();
443                    if self.tool_names.contains(&tool_name) {
444                        return Err(agent_base::AgentError::internal(format!(
445                            "Tool name conflict: `{}` (Skill `{}`)",
446                            tool_name,
447                            skill.name()
448                        )));
449                    }
450                    self.tool_names.insert(tool_name);
451                    ab = ab.register_tool_arc(tool);
452                }
453                skill_refs.push(skill);
454            }
455
456            if !self.disable_skill_prompt_injection {
457                let skill_prompt = prompter.build_prompt(&skill_refs, &self.skill_detail_tool_name);
458                if !skill_prompt.is_empty() {
459                    let new_prompt = match self.system_prompt.take() {
460                        Some(existing) => format!("{}\n\n---\n\n{}", existing, skill_prompt),
461                        None => skill_prompt,
462                    };
463                    self.system_prompt = Some(new_prompt.clone());
464                    ab = ab.system_prompt(new_prompt);
465                }
466            }
467
468            // Use injected factory if available, otherwise skip — prompt-injection
469            // mode uses read_file instead of a dedicated detail tool.
470            if let Some(factory) = self.skill_detail_tool_factory.take() {
471                let detail_tool = factory(skill_refs.clone(), self.skill_detail_tool_name);
472                ab = ab.register_tool_arc(detail_tool);
473            }
474
475            // Create SkillRegistry and populate it for the list-skills tool
476            if let Some(factory) = self.list_skills_tool_factory.take() {
477                let registry = Arc::new(crate::skill::SkillRegistry::new());
478                for skill in &skill_refs {
479                    tokio::task::block_in_place(|| {
480                        tokio::runtime::Handle::current().block_on(async {
481                            registry.register(skill.clone()).await;
482                        })
483                    });
484                }
485                let list_tool = factory(registry);
486                ab = ab.register_tool_arc(list_tool);
487            }
488        }
489
490        // Inject multi-agent prompt
491        if ma_config.as_ref().map(|c| c.enabled).unwrap_or(false) {
492            let ma_prompt = build_multi_agent_system_prompt();
493            let new_prompt = match self.system_prompt.take() {
494                Some(existing) => format!("{}\n\n---\n\n{}", existing, ma_prompt),
495                None => ma_prompt,
496            };
497            ab = ab.system_prompt(new_prompt);
498        }
499
500        let runtime = ab.build()?;
501
502        // Post-build: register multi-agent tools
503        if let Some(config) = ma_config
504            && config.enabled
505        {
506            setup_multi_agent(
507                &runtime,
508                config,
509                lang,
510                business_tools,
511                error_recovery,
512                &tool_names,
513                ma_tool_factory,
514            )?;
515        }
516
517        Ok(runtime)
518    }
519}
520
521/// Set up the MultiAgentRuntime, event bridge, and register tools on an already-built runtime.
522///
523/// # Safety / Runtime Requirement
524///
525/// This function uses [`tokio::task::block_in_place`] to register tools synchronously.
526/// It **requires** a multi-threaded tokio runtime. Calling it on a
527/// `#[tokio::main]` single-threaded (`current_thread`) runtime will panic.
528///
529/// The phi-agent CLI and all examples use the default multi-threaded runtime,
530/// so this is safe in practice.
531pub fn setup_multi_agent(
532    runtime: &AgentRuntime,
533    config: MultiAgentConfig,
534    lang: agent_base::Language,
535    business_tools: Vec<Arc<dyn Tool>>,
536    error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
537    existing_tool_names: &HashSet<String>,
538    tool_factory: Option<MultiAgentToolFactory>,
539) -> AgentResult<Arc<MultiAgentRuntime>> {
540    let client = runtime.client();
541    let cancel_token = runtime.cancel_token();
542    let tool_policy = runtime.tool_policy().cloned();
543    let approval_handler = runtime.approval_handler().cloned();
544
545    let ma_runtime = Arc::new(MultiAgentRuntime::new(
546        config.clone(),
547        client,
548        business_tools,
549        cancel_token,
550        error_recovery,
551        lang,
552        tool_policy,
553        approval_handler,
554    ));
555
556    // Set parent session manager for fork_history support
557    ma_runtime.set_session_manager(Arc::new(runtime.session_manager().clone()));
558
559    // Set up event bridge: child events → parent event bus
560    let (event_tx, mut event_rx) =
561        tokio::sync::mpsc::unbounded_channel::<agent_base::RuntimeEvent>();
562    ma_runtime.set_event_sender(event_tx);
563    let parent_runtime = runtime.clone();
564    tokio::spawn(async move {
565        while let Some(event) = event_rx.recv().await {
566            parent_runtime.emit_event(event);
567        }
568    });
569
570    // Register multi-agent tools if a factory is provided
571    if let Some(factory) = tool_factory {
572        let tools = factory(ma_runtime.clone());
573        let registry = runtime.tools_mut();
574        let mut reg = tokio::task::block_in_place(|| registry.blocking_write());
575        for tool in tools {
576            let tool_name = tool.name().to_string();
577            if !existing_tool_names.contains(&tool_name) {
578                reg.register_arc(tool);
579            }
580        }
581        drop(reg);
582    }
583
584    Ok(ma_runtime)
585}
586
587/// Build the multi-agent system prompt guidance for the main agent.
588pub fn build_multi_agent_system_prompt() -> String {
589    r#"## Multi-Agent Capabilities
590
591You have the ability to spawn sub-agents to execute tasks concurrently. Use these tools to delegate work:
592
593- `spawn_agent`: Create a new sub-agent with a specific role. The agent runs independently.
594- `send_message`: Send a message to a sub-agent without triggering execution.
595- `followup_task`: Assign a task to a sub-agent and trigger its execution. Returns immediately.
596- `wait_agent`: Wait for a sub-agent's result. Blocks until the agent completes or timeout.
597- `list_agents`: List all active sub-agents and their status.
598- `close_agent`: Close a sub-agent and release its resources.
599
600### When to Spawn
601
602- Tasks that can run independently and in parallel (e.g., "research X and Y simultaneously")
603- Long-running tasks where you want to check intermediate results
604- Decomposing complex tasks into sub-tasks for focused execution
605
606### When NOT to Spawn
607
608- Simple lookups or single-tool calls (just use the tool directly)
609- Sequential dependencies where the next step requires the previous result
610- Tasks that need your full context or reasoning
611
612### Communication Pattern
613
6141. `spawn_agent` → create the sub-agent
6152. `followup_task` → assign work (can call multiple times)
6163. `wait_agent` → collect results
6174. `close_agent` → clean up when done"#
618        .to_string()
619}
620
621/// Build the memory system prompt guidance.
622///
623/// Tells the LLM how to use the file-based persistent memory system.
624/// Memory is stored as markdown files — the LLM uses `read_file` / `write_file`
625/// to manage them, following the same convention as Claude Code Memory.
626///
627/// This is prompt-injection only — no dedicated memory tools are registered.
628/// The LLM uses the general-purpose file tools to read/write memory files.
629pub fn build_memory_system_prompt() -> String {
630    r#"## Memory
631
632You have a persistent file-based memory at `.phi/memory/`. Use `read_file` and `write_file` to manage it — there are no dedicated memory tools.
633
634### How Memory Works
635
636- `MEMORY.md` is the index — it lists all memories with one-line descriptions. Read it first when you need to recall something.
637- Each memory is a separate `.md` file with YAML frontmatter:
638  ```yaml
639  ---
640  name: <short-kebab-case-slug>
641  description: <one-line summary — used to decide relevance during recall>
642  metadata:
643    node_type: memory
644    type: user | feedback | project | reference
645  ---
646
647  <the fact or instruction>
648  ```
649- 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.
650- Link related memories with `[[memory-name]]` in the body.
651- `user` type = who the user is (role, expertise, preferences).
652- `feedback` type = guidance the user has given on how you should work.
653- `project` type = ongoing work, goals, or constraints.
654- `reference` type = pointers to external resources (URLs, dashboards, tickets).
655
656### When to Use Memory
657
658- The user explicitly asks you to remember something ("remember this", "save that")
659- You learn something important about the user's preferences or workflow
660- After completing a significant task, save context that would help in future sessions
661- The user gives you feedback on how to work — save it as `feedback` type
662
663### When NOT to Use Memory
664
665- For transient information that won't be useful beyond this session
666- For facts already recorded in the codebase (code structure, git history, config files)
667- For items that only matter to the current conversation
668
669### Pro Tips
670
671- When creating your first memory of a new type, you can read template files for format reference (check `.phi/templates/memory/` if available).
672- Keep the MEMORY.md index concise — it's loaded into context every session.
673- Before writing a new memory, check if an existing file already covers it — update instead of duplicating.
674
675### Workflow
676
677**To recall:** read `MEMORY.md` → find relevant entries by description → read the specific `.md` files you need.
678**To remember:** create a new `.md` file with proper frontmatter → update `MEMORY.md` with a new entry.
679**To update:** edit the existing `.md` file (don't create a duplicate).
680**To forget:** delete the `.md` file → remove its entry from `MEMORY.md`."#
681        .to_string()
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use agent_base::{Content, LlmClient};
688    use std::pin::Pin;
689
690    // ── Stub LLM client ──
691
692    struct StubClient;
693
694    #[async_trait::async_trait]
695    impl LlmClient for StubClient {
696        async fn chat(
697            &self,
698            _messages: &[agent_base::ChatMessage],
699            _tools: &[serde_json::Value],
700            _reasoning: Option<&agent_base::ReasoningConfig>,
701            _response_format: Option<&agent_base::ResponseFormat>,
702        ) -> AgentResult<serde_json::Value> {
703            Ok(serde_json::json!({"choices": [{"message": {"content": "ok"}}]}))
704        }
705
706        async fn chat_stream(
707            &self,
708            _messages: &[agent_base::ChatMessage],
709            _tools: &[serde_json::Value],
710            _reasoning: Option<&agent_base::ReasoningConfig>,
711            _response_format: Option<&agent_base::ResponseFormat>,
712        ) -> AgentResult<
713            Pin<Box<dyn futures_core::Stream<Item = AgentResult<agent_base::StreamChunk>> + Send>>,
714        > {
715            let chunks: Vec<AgentResult<agent_base::StreamChunk>> = vec![
716                Ok(agent_base::StreamChunk::Text("ok".to_string())),
717                Ok(agent_base::StreamChunk::Stop {
718                    finish_reason: Some("stop".to_string()),
719                }),
720            ];
721            Ok(Box::pin(futures_util::stream::iter(chunks)))
722        }
723
724        fn capabilities(&self) -> agent_base::LlmCapabilities {
725            agent_base::LlmCapabilities {
726                supports_streaming: true,
727                supports_tools: true,
728                supports_vision: false,
729                supports_thinking: false,
730                max_context_tokens: None,
731                max_output_tokens: None,
732            }
733        }
734    }
735
736    fn make_client() -> Arc<dyn StreamClient> {
737        agent_base::llm::adapt(Arc::new(StubClient))
738    }
739
740    // ── setup_multi_agent tests ──
741
742    #[tokio::test(flavor = "multi_thread")]
743    async fn test_setup_multi_agent_without_factory_registers_no_tools() {
744        let client = make_client();
745        let runtime = agent_base::AgentBuilder::new(client.clone())
746            .build()
747            .unwrap();
748        let config = MultiAgentConfig::enabled();
749
750        let result = setup_multi_agent(
751            &runtime,
752            config,
753            agent_base::Language::En,
754            vec![],
755            None,
756            &HashSet::new(),
757            None, // no factory
758        );
759        assert!(result.is_ok());
760        let ma_runtime = result.unwrap();
761        // Verify no tools were registered (the 6 multi-agent tools are absent)
762        let agents = ma_runtime.list_agents();
763        assert!(agents.is_empty());
764    }
765
766    #[tokio::test(flavor = "multi_thread")]
767    async fn test_setup_multi_agent_with_factory_registers_tools() {
768        let client = make_client();
769        let runtime = agent_base::AgentBuilder::new(client.clone())
770            .build()
771            .unwrap();
772        let config = MultiAgentConfig::enabled();
773
774        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
775            // Minimal factory returning a single fake tool
776            struct FakeTool;
777            #[async_trait::async_trait]
778            impl Tool for FakeTool {
779                fn name(&self) -> &'static str {
780                    "fake_tool"
781                }
782                fn description(&self) -> &'static str {
783                    ""
784                }
785                fn schema(&self) -> serde_json::Value {
786                    serde_json::json!({})
787                }
788                async fn call(
789                    &self,
790                    _args: &serde_json::Value,
791                    _ctx: &agent_base::ToolContext,
792                ) -> AgentResult<Vec<Content>> {
793                    Ok(vec![Content::text("ok")])
794                }
795            }
796            vec![Arc::new(FakeTool)]
797        });
798
799        let result = setup_multi_agent(
800            &runtime,
801            config,
802            agent_base::Language::En,
803            vec![],
804            None,
805            &HashSet::new(),
806            Some(factory),
807        );
808        assert!(result.is_ok());
809
810        // Check the tool was registered on the runtime
811        let tools: Vec<String> = tokio::task::block_in_place(|| {
812            let tools = runtime.tools_mut();
813            let guard = tools.blocking_read();
814            guard.metadatas().into_iter().map(|m| m.name).collect()
815        });
816        assert!(tools.contains(&"fake_tool".to_string()));
817    }
818
819    #[tokio::test(flavor = "multi_thread")]
820    async fn test_setup_multi_agent_skips_duplicate_tool_names() {
821        let client = make_client();
822        let runtime = agent_base::AgentBuilder::new(client.clone())
823            .build()
824            .unwrap();
825
826        // Pre-register a tool with a conflicting name
827        struct DupTool;
828        #[async_trait::async_trait]
829        impl Tool for DupTool {
830            fn name(&self) -> &'static str {
831                "dup_tool"
832            }
833            fn description(&self) -> &'static str {
834                ""
835            }
836            fn schema(&self) -> serde_json::Value {
837                serde_json::json!({})
838            }
839            async fn call(
840                &self,
841                _args: &serde_json::Value,
842                _ctx: &agent_base::ToolContext,
843            ) -> AgentResult<Vec<Content>> {
844                Ok(vec![Content::text("ok")])
845            }
846        }
847        {
848            let tools = runtime.tools_mut();
849            let mut reg = tokio::task::block_in_place(|| tools.blocking_write());
850            reg.register(DupTool);
851        }
852
853        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
854            struct FakeTool;
855            #[async_trait::async_trait]
856            impl Tool for FakeTool {
857                fn name(&self) -> &'static str {
858                    "dup_tool"
859                }
860                fn description(&self) -> &'static str {
861                    ""
862                }
863                fn schema(&self) -> serde_json::Value {
864                    serde_json::json!({})
865                }
866                async fn call(
867                    &self,
868                    _args: &serde_json::Value,
869                    _ctx: &agent_base::ToolContext,
870                ) -> AgentResult<Vec<Content>> {
871                    Ok(vec![Content::text("ok")])
872                }
873            }
874            vec![Arc::new(FakeTool)]
875        });
876
877        let mut existing = HashSet::new();
878        existing.insert("dup_tool".to_string());
879
880        let result = setup_multi_agent(
881            &runtime,
882            MultiAgentConfig::enabled(),
883            agent_base::Language::En,
884            vec![],
885            None,
886            &existing,
887            Some(factory),
888        );
889        assert!(result.is_ok());
890        // dup_tool should NOT have been registered twice
891        let tools = tokio::task::block_in_place(|| {
892            let tools = runtime.tools_mut();
893            let guard = tools.blocking_read();
894            guard
895                .metadatas()
896                .into_iter()
897                .map(|m| m.name)
898                .collect::<Vec<String>>()
899        });
900        let count = tools.iter().filter(|n| n.as_str() == "dup_tool").count();
901        assert_eq!(count, 1);
902    }
903
904    // ── AgentBuilder factory methods ──
905
906    #[tokio::test(flavor = "multi_thread")]
907    async fn test_builder_with_multi_agent_without_factory_builds_ok() {
908        let client = make_client();
909        let runtime = AgentBuilder::new(client)
910            .with_multi_agent(MultiAgentConfig::enabled())
911            .build()
912            .unwrap();
913        // Should succeed even without a factory (no tools registered)
914        let tools = tokio::task::block_in_place(|| {
915            let tools = runtime.tools_mut();
916            let guard = tools.blocking_read();
917            guard
918                .metadatas()
919                .into_iter()
920                .map(|m| m.name)
921                .collect::<Vec<String>>()
922        });
923        // No multi-agent tools registered
924        assert!(!tools.contains(&"spawn_agent".to_string()));
925    }
926
927    #[tokio::test(flavor = "multi_thread")]
928    async fn test_builder_with_factory_registers_tools() {
929        let client = make_client();
930        // Create a simple factory that registers one recognizable tool
931        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
932            struct TestTool;
933            #[async_trait::async_trait]
934            impl Tool for TestTool {
935                fn name(&self) -> &'static str {
936                    "factory_test_tool"
937                }
938                fn description(&self) -> &'static str {
939                    ""
940                }
941                fn schema(&self) -> serde_json::Value {
942                    serde_json::json!({})
943                }
944                async fn call(
945                    &self,
946                    _args: &serde_json::Value,
947                    _ctx: &agent_base::ToolContext,
948                ) -> AgentResult<Vec<Content>> {
949                    Ok(vec![Content::text("ok")])
950                }
951            }
952            vec![Arc::new(TestTool)]
953        });
954
955        let runtime = AgentBuilder::new(client)
956            .with_multi_agent(MultiAgentConfig::enabled())
957            .with_multi_agent_tool_factory(factory)
958            .build()
959            .unwrap();
960
961        let tools = tokio::task::block_in_place(|| {
962            let tools = runtime.tools_mut();
963            let guard = tools.blocking_read();
964            guard
965                .metadatas()
966                .into_iter()
967                .map(|m| m.name)
968                .collect::<Vec<String>>()
969        });
970        assert!(tools.contains(&"factory_test_tool".to_string()));
971    }
972
973    #[test]
974    fn test_builder_disabled_multi_agent_skips_factory() {
975        let client = make_client();
976        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
977            panic!("factory should not be called when multi-agent is not configured");
978        });
979
980        let runtime = AgentBuilder::new(client)
981            .with_multi_agent_tool_factory(factory)
982            // Don't enable multi-agent — default (None) means disabled
983            .build()
984            .unwrap();
985
986        let tools = tokio::task::block_in_place(|| {
987            let tools = runtime.tools_mut();
988            let guard = tools.blocking_read();
989            guard
990                .metadatas()
991                .into_iter()
992                .map(|m| m.name)
993                .collect::<Vec<String>>()
994        });
995        assert!(!tools.contains(&"spawn_agent".to_string()));
996    }
997
998    // ── build_multi_agent_system_prompt ──
999
1000    #[test]
1001    fn test_system_prompt_contains_tool_names() {
1002        let prompt = build_multi_agent_system_prompt();
1003        assert!(prompt.contains("spawn_agent"));
1004        assert!(prompt.contains("send_message"));
1005        assert!(prompt.contains("followup_task"));
1006        assert!(prompt.contains("wait_agent"));
1007        assert!(prompt.contains("list_agents"));
1008        assert!(prompt.contains("close_agent"));
1009    }
1010
1011    #[test]
1012    fn test_system_prompt_contains_guidance() {
1013        let prompt = build_multi_agent_system_prompt();
1014        assert!(prompt.contains("When to Spawn"));
1015        assert!(prompt.contains("When NOT to Spawn"));
1016        assert!(prompt.contains("Communication Pattern"));
1017    }
1018
1019    // ── without_multi_agent ──
1020
1021    #[tokio::test(flavor = "multi_thread")]
1022    async fn test_without_multi_agent_clears_config_and_factory() {
1023        let client = make_client();
1024
1025        // Set up a factory that would panic if called — without_multi_agent should prevent it
1026        let factory: MultiAgentToolFactory = Arc::new(|_rt| {
1027            panic!("factory should not be called when multi-agent is cleared");
1028        });
1029
1030        let runtime = AgentBuilder::new(client)
1031            .with_multi_agent(MultiAgentConfig::enabled())
1032            .with_multi_agent_tool_factory(factory)
1033            .without_multi_agent() // clear both
1034            .build()
1035            .unwrap();
1036
1037        let tools = tokio::task::block_in_place(|| {
1038            let tools = runtime.tools_mut();
1039            let guard = tools.blocking_read();
1040            guard
1041                .metadatas()
1042                .into_iter()
1043                .map(|m| m.name)
1044                .collect::<Vec<String>>()
1045        });
1046        assert!(!tools.contains(&"spawn_agent".to_string()));
1047    }
1048
1049    // ── apply_if ──
1050
1051    #[test]
1052    fn test_apply_if_some_applies_transformation() {
1053        let client = make_client();
1054        let builder = AgentBuilder::new(client)
1055            .apply_if(Some("custom prompt"), |b, prompt| b.system_prompt(prompt));
1056        // system_prompt is stored in self.system_prompt; verify it was set
1057        assert!(builder.system_prompt.unwrap().contains("custom prompt"));
1058    }
1059
1060    #[test]
1061    fn test_apply_if_none_passes_through() {
1062        let client = make_client();
1063        let builder = AgentBuilder::new(client).apply_if(None as Option<&str>, |_b, _prompt| {
1064            panic!("should not be called when value is None");
1065        });
1066        assert!(builder.system_prompt.is_none());
1067    }
1068
1069    // ── build_memory_system_prompt ──
1070
1071    #[test]
1072    fn test_build_memory_system_prompt_non_empty() {
1073        let prompt = build_memory_system_prompt();
1074        assert!(!prompt.is_empty());
1075        assert!(prompt.contains("Memory"));
1076        assert!(prompt.contains("MEMORY.md"));
1077        assert!(prompt.contains("read_file"));
1078        assert!(prompt.contains("write_file"));
1079    }
1080
1081    // ── Named tool for register_tool / skill tests ──
1082
1083    struct NamedTool(&'static str);
1084
1085    #[async_trait::async_trait]
1086    impl Tool for NamedTool {
1087        fn name(&self) -> &'static str {
1088            self.0
1089        }
1090
1091        fn description(&self) -> &'static str {
1092            ""
1093        }
1094
1095        fn schema(&self) -> serde_json::Value {
1096            serde_json::json!({})
1097        }
1098
1099        async fn call(
1100            &self,
1101            _args: &serde_json::Value,
1102            _ctx: &agent_base::ToolContext,
1103        ) -> AgentResult<Vec<Content>> {
1104            Ok(vec![Content::text("ok")])
1105        }
1106    }
1107
1108    fn runtime_tool_names(runtime: &AgentRuntime) -> Vec<String> {
1109        tokio::task::block_in_place(|| {
1110            let tools = runtime.tools_mut();
1111            let guard = tools.blocking_read();
1112            guard.metadatas().into_iter().map(|m| m.name).collect()
1113        })
1114    }
1115
1116    #[tokio::test(flavor = "multi_thread")]
1117    async fn test_builder_scalar_passthrough_methods() {
1118        let client = make_client();
1119        let runtime = AgentBuilder::new(client)
1120            .enable_thought(true)
1121            .reasoning(agent_base::ReasoningConfig::default())
1122            .enable_thinking(false)
1123            .thinking_budget(1000)
1124            .tool_timeout(5000)
1125            .max_tool_output_chars(4000)
1126            .max_sessions(16)
1127            .max_turns_per_session(20)
1128            .execution_max_turns(10)
1129            .max_message_tokens(8000)
1130            .context_window(64_000)
1131            .context_window_manager(agent_base::ContextWindowManager::new(64_000))
1132            .response_format(agent_base::ResponseFormat::JsonObject)
1133            .llm_retry(agent_base::RetryConfig::default())
1134            .tool_error_retry_prompt("please retry")
1135            .language(agent_base::Language::En)
1136            .event_bus_capacity(256)
1137            .build()
1138            .unwrap();
1139
1140        assert!(runtime.client().capabilities().supports_streaming);
1141    }
1142
1143    #[tokio::test(flavor = "multi_thread")]
1144    async fn test_register_tool_variants() {
1145        let client = make_client();
1146        let runtime = AgentBuilder::new(client)
1147            .register_tool(NamedTool("tool_by_value"))
1148            .register_tool_arc(Arc::new(NamedTool("tool_by_arc")))
1149            .build()
1150            .unwrap();
1151
1152        let names = runtime_tool_names(&runtime);
1153        assert!(names.contains(&"tool_by_value".to_string()));
1154        assert!(names.contains(&"tool_by_arc".to_string()));
1155    }
1156
1157    #[cfg(feature = "skill")]
1158    mod skill_tests {
1159        use super::*;
1160        use crate::skill::Skill;
1161
1162        struct TestSkill;
1163
1164        impl Skill for TestSkill {
1165            fn name(&self) -> &'static str {
1166                "test_skill"
1167            }
1168
1169            fn brief_description(&self) -> String {
1170                "a test skill".to_string()
1171            }
1172
1173            fn detailed_description(&self) -> String {
1174                "detailed test skill".to_string()
1175            }
1176
1177            fn tools(&self) -> Vec<Arc<dyn Tool>> {
1178                vec![]
1179            }
1180        }
1181
1182        struct ToolSkill;
1183
1184        impl Skill for ToolSkill {
1185            fn name(&self) -> &'static str {
1186                "tool_skill"
1187            }
1188
1189            fn brief_description(&self) -> String {
1190                "skill with a tool".to_string()
1191            }
1192
1193            fn detailed_description(&self) -> String {
1194                "skill that provides a tool".to_string()
1195            }
1196
1197            fn tools(&self) -> Vec<Arc<dyn Tool>> {
1198                vec![Arc::new(NamedTool("skill_provided_tool"))]
1199            }
1200        }
1201
1202        #[tokio::test(flavor = "multi_thread")]
1203        async fn test_register_skill_builds_ok() {
1204            let client = make_client();
1205            let runtime = AgentBuilder::new(client)
1206                .register_skill(TestSkill)
1207                .build()
1208                .unwrap();
1209            // Prompt injection is applied during build; no tools provided.
1210            assert!(runtime_tool_names(&runtime).is_empty());
1211        }
1212
1213        #[tokio::test(flavor = "multi_thread")]
1214        async fn test_register_skill_with_tool_registers_tool() {
1215            let client = make_client();
1216            let runtime = AgentBuilder::new(client)
1217                .register_skill(ToolSkill)
1218                .build()
1219                .unwrap();
1220            assert!(runtime_tool_names(&runtime).contains(&"skill_provided_tool".to_string()));
1221        }
1222
1223        #[tokio::test(flavor = "multi_thread")]
1224        async fn test_register_skill_tool_name_conflict() {
1225            let client = make_client();
1226            let result = AgentBuilder::new(client)
1227                .register_tool(NamedTool("skill_provided_tool"))
1228                .register_skill(ToolSkill)
1229                .build();
1230            let err = result.err().unwrap();
1231            assert!(format!("{err}").contains("Tool name conflict"));
1232        }
1233
1234        #[tokio::test(flavor = "multi_thread")]
1235        async fn test_skill_detail_tool_factory_registers_tool() {
1236            let client = make_client();
1237            let factory: SkillDetailToolFactory = Arc::new(|_skills, name| {
1238                assert_eq!(name, "get_skill_detail");
1239                Arc::new(NamedTool("detail_tool"))
1240            });
1241            let runtime = AgentBuilder::new(client)
1242                .register_skill(TestSkill)
1243                .with_skill_detail_tool_factory(factory)
1244                .build()
1245                .unwrap();
1246            assert!(runtime_tool_names(&runtime).contains(&"detail_tool".to_string()));
1247        }
1248
1249        #[tokio::test(flavor = "multi_thread")]
1250        async fn test_list_skills_tool_factory_registers_tool() {
1251            let client = make_client();
1252            let factory: ListSkillsToolFactory =
1253                Arc::new(|_registry| Arc::new(NamedTool("list_skills_tool")));
1254            let runtime = AgentBuilder::new(client)
1255                .register_skill(TestSkill)
1256                .with_list_skills_tool_factory(factory)
1257                .build()
1258                .unwrap();
1259            assert!(runtime_tool_names(&runtime).contains(&"list_skills_tool".to_string()));
1260        }
1261
1262        #[tokio::test(flavor = "multi_thread")]
1263        async fn test_disable_skill_prompt_injection_builds() {
1264            let client = make_client();
1265            let runtime = AgentBuilder::new(client)
1266                .register_skill(ToolSkill)
1267                .disable_skill_prompt_injection()
1268                .build()
1269                .unwrap();
1270            // Tool still registered; prompt injection skipped.
1271            assert!(runtime_tool_names(&runtime).contains(&"skill_provided_tool".to_string()));
1272        }
1273    }
1274}