Skip to main content

defect_cli/
assembly.rs

1//! CLI default AgentCore assembler.
2//!
3//! This layer consolidates the provider / tool / hook / storage / MCP / observability
4//! wiring that was previously scattered across `src/bin/cli.rs` into an extensible
5//! builder. The underlying
6//! [`DefaultAgentCoreBuilder`](defect_agent::session::DefaultAgentCoreBuilder) remains a
7//! minimal agent abstraction; this module expresses the defect CLI's "default feature
8//! set".
9
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use agent_client_protocol_schema::SessionId;
15use defect_agent::hooks::HookEngine;
16use defect_agent::hooks::builtin::BuiltinRegistry;
17use defect_agent::llm::{ProviderEntry, ProviderRegistry};
18use defect_agent::policy::{ModeCatalog, NonInteractivePolicy, SandboxPolicy};
19use defect_agent::session::{
20    AgentCore, DefaultAgentCore, SessionObserver, SessionToolFactory, StaticToolRegistry,
21    ToolRegistry, TurnConfig,
22};
23use defect_agent::tool::{SkillEntry, Tool};
24use defect_config::{HooksConfig, LoadConfigOptions, LoadedConfig, ProfileSpec, SandboxMode};
25use defect_mcp::McpToolFactory;
26use defect_storage::StorageObserver;
27
28use crate::hooks::{self, HookEngineCtx};
29use crate::http_stack::build_http_stack_config;
30use crate::mcp_servers::build_default_mcp_servers;
31use crate::observability;
32use crate::paths::{default_sessions_root, local_sessions_root};
33use crate::policy::{build_mode_catalog, build_policy};
34use crate::providers::{build_provider_entries, build_registry};
35use crate::tools::{
36    build_process_tools, build_process_tools_with_subagents, filter_tools_by_allowlist,
37    project_skills,
38};
39
40const SKILL_MANIFEST_HOOK_NAME: &str = "skill-manifest";
41const SKILL_TRIGGERS_HOOK_NAME: &str = "skill-triggers";
42
43/// Clippable features in the default CLI assembly.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum DefaultFeature {
46    /// Default process-level tools: bash / fs / fetch / search.
47    ProcessTools,
48    /// Profile-driven `spawn_agent` and background task control tools.
49    Subagents,
50    /// Skill tools and automatic skill hooks.
51    Skills,
52    /// Hooks from the user configuration.
53    Hooks,
54    /// Session persistence and `--resume`.
55    Storage,
56    /// Default MCP server factory.
57    Mcp,
58    /// Bypass observers such as langfuse.
59    Observability,
60    /// HTTP fetch backend.
61    Http,
62    /// ACP-exposed permission mode directory.
63    Modes,
64}
65
66/// Default feature set for the CLI.
67#[derive(Debug, Clone)]
68pub struct DefaultFeatureSet {
69    process_tools: bool,
70    subagents: bool,
71    skills: bool,
72    hooks: bool,
73    storage: bool,
74    mcp: bool,
75    observability: bool,
76    http: bool,
77    modes: bool,
78}
79
80impl Default for DefaultFeatureSet {
81    fn default() -> Self {
82        Self {
83            process_tools: true,
84            subagents: true,
85            skills: true,
86            hooks: true,
87            storage: true,
88            mcp: true,
89            observability: true,
90            http: true,
91            modes: true,
92        }
93    }
94}
95
96impl DefaultFeatureSet {
97    /// An empty feature set. Useful when the host only wants to reuse configuration
98    /// parsing and then explicitly enable features one by one.
99    pub fn empty() -> Self {
100        Self {
101            process_tools: false,
102            subagents: false,
103            skills: false,
104            hooks: false,
105            storage: false,
106            mcp: false,
107            observability: false,
108            http: false,
109            modes: false,
110        }
111    }
112
113    /// Disables a default feature.
114    pub fn without(mut self, feature: DefaultFeature) -> Self {
115        self.set(feature, false);
116        self
117    }
118
119    /// Enables a default feature.
120    pub fn with(mut self, feature: DefaultFeature) -> Self {
121        self.set(feature, true);
122        self
123    }
124
125    fn set(&mut self, feature: DefaultFeature, enabled: bool) {
126        match feature {
127            DefaultFeature::ProcessTools => self.process_tools = enabled,
128            DefaultFeature::Subagents => self.subagents = enabled,
129            DefaultFeature::Skills => self.skills = enabled,
130            DefaultFeature::Hooks => self.hooks = enabled,
131            DefaultFeature::Storage => self.storage = enabled,
132            DefaultFeature::Mcp => self.mcp = enabled,
133            DefaultFeature::Observability => self.observability = enabled,
134            DefaultFeature::Http => self.http = enabled,
135            DefaultFeature::Modes => self.modes = enabled,
136        }
137    }
138}
139
140/// REPL assembly semantics.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum ReplMode {
143    /// Uses the ACP server path.
144    Disabled,
145    /// Uses the built-in REPL; the default sandbox switches to `open` to avoid hanging
146    /// due to missing UI permission confirmation.
147    Enabled,
148}
149
150/// AgentCore and associated metadata required to run the default CLI.
151pub struct BuiltCliAgent {
152    pub agent: Arc<dyn AgentCore>,
153    pub resume_session_id: Option<SessionId>,
154    pub sandbox_mode: SandboxMode,
155    pub turn_config: TurnConfig,
156    /// Shared state handle for `--goal` mode (`None` otherwise). The oneshot runner reads
157    /// [`GoalState::is_reached`](defect_agent::session::GoalState::is_reached) after each
158    /// turn — if retries are exhausted without reaching the goal, it exits with a
159    /// non-zero code so CI does not mistake "ran all turns without success" for a pass.
160    ///
161    /// [`GoalState`]: defect_agent::session::GoalState
162    pub goal: Option<Arc<defect_agent::session::GoalState>>,
163}
164
165/// Default AgentCore assembly builder for the defect CLI.
166pub struct CliAgentBuilder {
167    cwd: PathBuf,
168    load_options: LoadConfigOptions,
169    config: LoadedConfig,
170    features: DefaultFeatureSet,
171    repl: ReplMode,
172    local_sessions: bool,
173    profile: Option<String>,
174    resume: Option<Option<String>>,
175    registry_override: Option<Arc<ProviderRegistry>>,
176    extra_provider_entries: Vec<ProviderEntry>,
177    process_tools_override: Option<Arc<dyn ToolRegistry>>,
178    extra_process_tools: Vec<Arc<dyn Tool>>,
179    extra_process_registries: Vec<Arc<dyn ToolRegistry>>,
180    policy_override: Option<Arc<dyn SandboxPolicy>>,
181    non_interactive: bool,
182    goal: Option<Arc<defect_agent::session::GoalState>>,
183    /// `--max-turns`: maximum number of before_turn_end continuations in goal mode,
184    /// mapped to
185    /// `TurnConfig::max_hook_continues`. `None` = use config/default.
186    max_turns: Option<u32>,
187    modes_override: Option<ModeCatalog>,
188    hook_engine_override: Option<Arc<dyn HookEngine>>,
189    builtin_registry: BuiltinRegistry,
190    session_tool_factory_override: Option<Arc<dyn SessionToolFactory>>,
191    observers: Vec<Arc<dyn SessionObserver>>,
192}
193
194impl CliAgentBuilder {
195    /// Creates a CLI agent builder with the full default feature set.
196    pub fn new(cwd: PathBuf, load_options: LoadConfigOptions, config: LoadedConfig) -> Self {
197        Self {
198            cwd,
199            load_options,
200            config,
201            features: DefaultFeatureSet::default(),
202            repl: ReplMode::Disabled,
203            local_sessions: false,
204            profile: None,
205            resume: None,
206            registry_override: None,
207            extra_provider_entries: Vec::new(),
208            process_tools_override: None,
209            extra_process_tools: Vec::new(),
210            extra_process_registries: Vec::new(),
211            policy_override: None,
212            non_interactive: false,
213            goal: None,
214            max_turns: None,
215            modes_override: None,
216            hook_engine_override: None,
217            builtin_registry: BuiltinRegistry::defaults(),
218            session_tool_factory_override: None,
219            observers: Vec::new(),
220        }
221    }
222
223    /// Replace the default feature set.
224    pub fn features(mut self, features: DefaultFeatureSet) -> Self {
225        self.features = features;
226        self
227    }
228
229    /// Enable REPL assembly semantics.
230    pub fn repl(mut self, repl: ReplMode) -> Self {
231        self.repl = repl;
232        self
233    }
234
235    /// Use the project-local session directory.
236    pub fn local_sessions(mut self) -> Self {
237        self.local_sessions = true;
238        self
239    }
240
241    /// Set the top-level profile.
242    pub fn profile(mut self, profile: impl Into<String>) -> Self {
243        self.profile = Some(profile.into());
244        self
245    }
246
247    /// Set the resume parameter. `None` means bare `--resume`, which looks up the most
248    /// recent session by cwd.
249    pub fn resume(mut self, session_id: Option<String>) -> Self {
250        self.resume = Some(session_id);
251        self
252    }
253
254    /// Overrides the provider registry directly.
255    pub fn provider_registry(mut self, registry: Arc<ProviderRegistry>) -> Self {
256        self.registry_override = Some(registry);
257        self
258    }
259
260    /// Appends a provider entry after the default provider entries.
261    pub fn add_provider_entry(mut self, entry: ProviderEntry) -> Self {
262        self.extra_provider_entries.push(entry);
263        self
264    }
265
266    /// Overrides the process-level tool registry directly.
267    pub fn process_tools(mut self, tools: Arc<dyn ToolRegistry>) -> Self {
268        self.process_tools_override = Some(tools);
269        self
270    }
271
272    /// Adds a tool on top of the default process-level tools. When names conflict, the
273    /// added tool takes precedence.
274    pub fn add_tool(mut self, tool: Arc<dyn Tool>) -> Self {
275        self.extra_process_tools.push(tool);
276        self
277    }
278
279    /// Overlay a registry onto the default process-level tools. Registries added later
280    /// take higher priority.
281    pub fn add_tool_registry(mut self, registry: Arc<dyn ToolRegistry>) -> Self {
282        self.extra_process_registries.push(registry);
283        self
284    }
285
286    /// Overrides the sandbox policy.
287    pub fn policy(mut self, policy: Arc<dyn SandboxPolicy>) -> Self {
288        self.policy_override = Some(policy);
289        self
290    }
291
292    /// Wraps the final policy with [`NonInteractivePolicy`]: downgrades inner `Ask` to
293    /// `Deny` to prevent hanging on permission prompts in non‑TTY environments
294    /// (`--message` single‑turn mode). `Allow` / `Deny` pass through unchanged.
295    pub fn non_interactive(mut self) -> Self {
296        self.non_interactive = true;
297        self
298    }
299
300    /// Enable the `--goal` goal-driven loop: registers the `goal_done` tool, installs a
301    /// `goal-gate` hook (`before_turn_end`), and attaches [`GoalState`] to the session.
302    /// The agent runs autonomously for multiple turns until `goal_done` is called
303    /// (success) or the `max_hook_continues` limit (`--max-turns`) is reached.
304    ///
305    /// [`GoalState`]: defect_agent::session::GoalState
306    pub fn goal(mut self, objective: impl Into<String>) -> Self {
307        self.goal = Some(Arc::new(defect_agent::session::GoalState::new(
308            objective.into(),
309        )));
310        self
311    }
312
313    /// `--max-turns`: the maximum number of times `before_turn_end` can extend the
314    /// session in goal mode (mapped to
315    /// `TurnConfig::max_hook_continues`). When the limit is reached, the session is
316    /// forcibly stopped and exits with `Exhausted`.
317    pub fn max_turns(mut self, max_turns: u32) -> Self {
318        self.max_turns = Some(max_turns);
319        self
320    }
321
322    /// Override the permission mode catalog.
323    pub fn modes(mut self, modes: ModeCatalog) -> Self {
324        self.modes_override = Some(modes);
325        self
326    }
327
328    /// Override the hook engine.
329    pub fn hook_engine(mut self, hook_engine: Arc<dyn HookEngine>) -> Self {
330        self.hook_engine_override = Some(hook_engine);
331        self
332    }
333
334    /// Registers a builtin hook factory.
335    pub fn builtin_registry(mut self, registry: BuiltinRegistry) -> Self {
336        self.builtin_registry = registry;
337        self
338    }
339
340    /// Override the session-level tool factory, e.g. for custom MCP integration.
341    pub fn session_tool_factory(mut self, factory: Arc<dyn SessionToolFactory>) -> Self {
342        self.session_tool_factory_override = Some(factory);
343        self
344    }
345
346    /// Adds a session observer. The observer can subscribe to the event stream after
347    /// session creation and push events to an external system.
348    pub fn observe_session(mut self, observer: Arc<dyn SessionObserver>) -> Self {
349        self.observers.push(observer);
350        self
351    }
352
353    /// Builds an [`AgentCore`] along with CLI companion information.
354    ///
355    /// # Errors
356    ///
357    /// Returns an error if configuration derivation fails, provider/hook/subagent tool
358    /// assembly fails, the persistence directory cannot be resolved, or an explicit
359    /// `resume` is requested but the target session is not found.
360    pub async fn build(mut self) -> anyhow::Result<BuiltCliAgent> {
361        let profiles = defect_config::discover_profiles(&self.load_options)
362            .map_err(|e| anyhow::anyhow!("profile discovery failed: {e}"))?;
363        let skill_specs = if self.features.skills {
364            defect_config::discover_skills(&self.load_options)
365                .map_err(|e| anyhow::anyhow!("skill discovery failed: {e}"))?
366        } else {
367            BTreeMap::new()
368        };
369        let skills = project_skills(&skill_specs);
370        let (registry, mut turn_config) = self.build_registry().await?;
371        apply_profile_to_turn_config(&mut turn_config, self.profile.as_deref(), &profiles)?;
372        // `--max-turns`: the maximum number of continues in goal mode. Maps to the hard
373        // cap on continues in `before_turn_end`.
374        if let Some(max_turns) = self.max_turns {
375            turn_config.max_hook_continues = max_turns;
376        }
377
378        let sandbox_mode = self.resolve_sandbox_mode();
379        let mut policy = self
380            .policy_override
381            .clone()
382            .unwrap_or_else(|| build_policy(sandbox_mode));
383        // Non-interactive mode (`--message`): wrap with `NonInteractivePolicy` and **do
384        // not** attach a `ModeCatalog`.
385        // Key: when `DefaultSession` has a catalog attached, the active policy comes from
386        // the catalog's current mode
387        // (`session_policy_state`), bypassing the `policy` set here — the wrapper becomes
388        // ineffective, `Ask` is not downgraded,
389        // and the process hangs forever without a TTY. `oneshot` has no `set_mode`
390        // client, so the catalog is meaningless anyway;
391        // set it to `None` so the session falls back to this wrapped policy.
392        let modes = if self.non_interactive {
393            policy = Arc::new(NonInteractivePolicy::new(policy));
394            None
395        } else {
396            self.modes_override.clone().or_else(|| {
397                self.features
398                    .modes
399                    .then(|| build_mode_catalog(sandbox_mode))
400            })
401        };
402
403        let skills_arc = Arc::new(skills.clone());
404        if self.features.skills {
405            register_skill_builtins(&mut self.builtin_registry, &skills_arc);
406        }
407        let builtin_registry = &self.builtin_registry;
408        let hook_rt = HookEngineCtx {
409            registry: &registry,
410            default_model: turn_config.model.as_str(),
411        };
412
413        let mut process_tools = self.build_process_tools(
414            &profiles,
415            &skills,
416            &registry,
417            &policy,
418            builtin_registry,
419            &hook_rt,
420        )?;
421        // `--goal` mode: overlay the `goal_done` tool so the model can declare a goal
422        // achieved.
423        if self.goal.is_some() {
424            process_tools = overlay_process_tools(
425                process_tools,
426                &[Arc::new(defect_agent::tool::GoalDoneTool::new()) as Arc<dyn Tool>],
427                &[],
428            );
429        }
430        let hook_engine = self.build_hook_engine(builtin_registry, &hook_rt, &skills_arc)?;
431        let storage = self.build_storage()?;
432        let resume_session_id = self.resolve_resume(storage.as_ref())?;
433        let langfuse = self.build_langfuse()?;
434        let http_client = self.build_http()?;
435
436        let mut core = DefaultAgentCore::builder()
437            .registry(registry)
438            .process_tools(process_tools)
439            .policy(policy)
440            .config(turn_config.clone())
441            .background_progress(self.config.effective.tools.background)
442            .hook_engine(hook_engine);
443        if let Some(modes) = modes {
444            core = core.modes(modes);
445        }
446        if let Some(goal) = &self.goal {
447            core = core.goal(goal.clone());
448        }
449        if let Some(storage) = storage {
450            core = core
451                .observe_session(storage.clone())
452                .session_loader(storage as Arc<dyn defect_agent::session::SessionLoader>);
453        }
454        if let Some(factory) = self.build_session_tool_factory() {
455            core = core.session_tool_factory(factory);
456        }
457        if let Some(http_client) = http_client {
458            core = core.http(http_client);
459        }
460        if let Some(langfuse) = langfuse {
461            core = core.observe_session(langfuse);
462        }
463        for observer in self.observers {
464            core = core.observe_session(observer);
465        }
466
467        Ok(BuiltCliAgent {
468            agent: Arc::new(core.build()) as Arc<dyn AgentCore>,
469            resume_session_id,
470            sandbox_mode,
471            turn_config,
472            goal: self.goal,
473        })
474    }
475
476    async fn build_registry(&self) -> anyhow::Result<(Arc<ProviderRegistry>, TurnConfig)> {
477        let turn_config = self.config.effective.turn.clone();
478        if let Some(registry) = &self.registry_override {
479            return Ok((registry.clone(), turn_config));
480        }
481        if self.extra_provider_entries.is_empty() {
482            return build_registry(&self.config).await;
483        }
484        let http_config = build_http_stack_config(&self.config.effective.http)?;
485        let mut entries = build_provider_entries(&self.config, http_config).await?;
486        entries.extend(self.extra_provider_entries.clone());
487        let registry = ProviderRegistry::new(entries, &turn_config.provider, &turn_config.model)
488            .map_err(|e| anyhow::anyhow!("provider registry init failed: {e}"))?;
489        Ok((Arc::new(registry), turn_config))
490    }
491
492    fn resolve_sandbox_mode(&self) -> SandboxMode {
493        match self.repl {
494            ReplMode::Disabled => self.config.effective.sandbox.mode,
495            ReplMode::Enabled => SandboxMode::Open,
496        }
497    }
498
499    fn build_process_tools(
500        &self,
501        profiles: &BTreeMap<String, ProfileSpec>,
502        skills: &BTreeMap<String, SkillEntry>,
503        registry: &Arc<ProviderRegistry>,
504        policy: &Arc<dyn SandboxPolicy>,
505        builtin_registry: &BuiltinRegistry,
506        hook_rt: &HookEngineCtx<'_>,
507    ) -> anyhow::Result<Arc<dyn ToolRegistry>> {
508        let base = match &self.process_tools_override {
509            Some(tools) => tools.clone(),
510            None if self.features.process_tools => self.build_default_process_tools(
511                profiles,
512                skills,
513                registry,
514                policy,
515                builtin_registry,
516                hook_rt,
517            )?,
518            None => Arc::new(StaticToolRegistry::empty()) as Arc<dyn ToolRegistry>,
519        };
520        Ok(overlay_process_tools(
521            base,
522            &self.extra_process_tools,
523            &self.extra_process_registries,
524        ))
525    }
526
527    fn build_default_process_tools(
528        &self,
529        profiles: &BTreeMap<String, ProfileSpec>,
530        skills: &BTreeMap<String, SkillEntry>,
531        registry: &Arc<ProviderRegistry>,
532        policy: &Arc<dyn SandboxPolicy>,
533        builtin_registry: &BuiltinRegistry,
534        hook_rt: &HookEngineCtx<'_>,
535    ) -> anyhow::Result<Arc<dyn ToolRegistry>> {
536        let Some(profile_name) = self.profile.as_deref() else {
537            if self.features.subagents || self.features.skills {
538                let base_prompt_text = resolve_base_prompt_text(&self.config)?;
539                let empty_profiles = BTreeMap::new();
540                let empty_skills = BTreeMap::new();
541                let enabled_profiles = if self.features.subagents {
542                    profiles
543                } else {
544                    &empty_profiles
545                };
546                let enabled_skills = if self.features.skills {
547                    skills
548                } else {
549                    &empty_skills
550                };
551                return build_process_tools_with_subagents(
552                    &self.config,
553                    enabled_profiles,
554                    enabled_skills,
555                    registry,
556                    policy,
557                    base_prompt_text,
558                    builtin_registry,
559                    hook_rt,
560                )
561                .map_err(|e| anyhow::anyhow!("subagent hook engine build failed: {e}"));
562            }
563            return Ok(build_process_tools(&self.config));
564        };
565
566        let spec = profiles
567            .get(profile_name)
568            .ok_or_else(|| unknown_profile_error(profile_name, profiles))?;
569        let base = build_process_tools(&self.config);
570        filter_tools_by_allowlist(&base, &spec.tool_allow).map_err(|name| {
571            anyhow::anyhow!("profile `{profile_name}` allows unknown tool `{name}`")
572        })
573    }
574
575    fn build_hook_engine(
576        &self,
577        builtin_registry: &BuiltinRegistry,
578        hook_rt: &HookEngineCtx<'_>,
579        skills: &Arc<BTreeMap<String, SkillEntry>>,
580    ) -> anyhow::Result<Arc<dyn HookEngine>> {
581        if let Some(hook_engine) = &self.hook_engine_override {
582            return Ok(hook_engine.clone());
583        }
584        // The `--goal` flag also needs the goal-gate hook attached, even if the user has
585        // configured neither `[hooks]` nor any skill.
586        if self.features.hooks || self.features.skills || self.goal.is_some() {
587            let empty_hooks = HooksConfig::default();
588            let hooks_config = if self.features.hooks {
589                &self.config.effective.hooks
590            } else {
591                &empty_hooks
592            };
593            return hooks::build_main_session_engine(
594                hooks_config,
595                builtin_registry,
596                hook_rt,
597                skills,
598                self.goal.as_ref(),
599            )
600            .map_err(|e| anyhow::anyhow!("hook engine build failed: {e}"));
601        }
602        Ok(Arc::new(defect_agent::hooks::NoopHookEngine) as Arc<dyn HookEngine>)
603    }
604
605    fn build_storage(&self) -> anyhow::Result<Option<Arc<StorageObserver>>> {
606        if !self.features.storage {
607            return Ok(None);
608        }
609        let sessions_root = if self.local_sessions {
610            local_sessions_root(&self.cwd)
611        } else {
612            default_sessions_root()?
613        };
614        Ok(Some(Arc::new(StorageObserver::new(sessions_root))))
615    }
616
617    fn resolve_resume(
618        &self,
619        storage: Option<&Arc<StorageObserver>>,
620    ) -> anyhow::Result<Option<SessionId>> {
621        match &self.resume {
622            None => Ok(None),
623            Some(Some(id)) => Ok(Some(SessionId::new(id.clone()))),
624            Some(None) => {
625                let Some(storage) = storage else {
626                    return Err(anyhow::anyhow!(
627                        "--resume requires the default storage feature or a session loader"
628                    ));
629                };
630                let id = storage
631                    .latest_session_id_for_cwd(&self.cwd)
632                    .map_err(|e| anyhow::anyhow!("failed to scan sessions for resume: {e}"))?
633                    .ok_or_else(|| {
634                        anyhow::anyhow!(
635                            "no previous session found for {} to --resume",
636                            self.cwd.display()
637                        )
638                    })?;
639                Ok(Some(id))
640            }
641        }
642    }
643
644    fn build_langfuse(&self) -> anyhow::Result<Option<Arc<dyn SessionObserver>>> {
645        if !self.features.observability {
646            return Ok(None);
647        }
648        let observer = observability::build_langfuse_observer(
649            self.config.effective.tracing.langfuse.as_ref(),
650            build_http_stack_config(&self.config.effective.http)?,
651        )?
652        .map(|observer| Arc::new(observer) as Arc<dyn SessionObserver>);
653        Ok(observer)
654    }
655
656    fn build_http(&self) -> anyhow::Result<Option<Arc<dyn defect_agent::http::HttpClient>>> {
657        if !self.features.http {
658            return Ok(None);
659        }
660        let http = defect_http::build_fetch_client_arc(&build_http_stack_config(
661            &self.config.effective.http,
662        )?)
663        .map_err(|e| anyhow::anyhow!("fetch http client init failed: {e}"))?;
664        Ok(Some(http))
665    }
666
667    fn build_session_tool_factory(&self) -> Option<Arc<dyn SessionToolFactory>> {
668        if let Some(factory) = &self.session_tool_factory_override {
669            return Some(factory.clone());
670        }
671        self.features.mcp.then(|| {
672            Arc::new(McpToolFactory::with_default_servers(
673                build_default_mcp_servers(&self.config),
674            )) as Arc<dyn SessionToolFactory>
675        })
676    }
677}
678
679fn apply_profile_to_turn_config(
680    turn_config: &mut TurnConfig,
681    profile_name: Option<&str>,
682    profiles: &BTreeMap<String, ProfileSpec>,
683) -> anyhow::Result<()> {
684    let Some(profile_name) = profile_name else {
685        return Ok(());
686    };
687    let spec = profiles
688        .get(profile_name)
689        .ok_or_else(|| unknown_profile_error(profile_name, profiles))?;
690    if let Some(model) = &spec.model {
691        turn_config.model = model.clone();
692    }
693    turn_config.system_prompt = Some(spec.system_prompt_text.clone());
694    Ok(())
695}
696
697fn unknown_profile_error(
698    profile_name: &str,
699    profiles: &BTreeMap<String, ProfileSpec>,
700) -> anyhow::Error {
701    anyhow::anyhow!(
702        "unknown --profile `{profile_name}`; available: {}",
703        profiles.keys().cloned().collect::<Vec<_>>().join(", ")
704    )
705}
706
707fn register_skill_builtins(
708    builtin_registry: &mut BuiltinRegistry,
709    skills: &Arc<BTreeMap<String, SkillEntry>>,
710) {
711    let skills_for_hook = skills.clone();
712    builtin_registry.register_step(SKILL_MANIFEST_HOOK_NAME, move || {
713        Arc::new(defect_agent::hooks::builtin::SkillManifestHook::new(
714            skills_for_hook.clone(),
715        ))
716    });
717    let skills_for_trig = skills.clone();
718    builtin_registry.register_step(SKILL_TRIGGERS_HOOK_NAME, move || {
719        Arc::new(defect_agent::hooks::builtin::SkillTriggersHook::new(
720            skills_for_trig.clone(),
721        ))
722    });
723}
724
725fn overlay_process_tools(
726    base: Arc<dyn ToolRegistry>,
727    tools: &[Arc<dyn Tool>],
728    registries: &[Arc<dyn ToolRegistry>],
729) -> Arc<dyn ToolRegistry> {
730    let mut current = base;
731    if !tools.is_empty() {
732        let mut builder = StaticToolRegistry::builder();
733        for tool in tools {
734            builder = builder.insert(tool.clone());
735        }
736        let overlay = Arc::new(builder.build()) as Arc<dyn ToolRegistry>;
737        current = Arc::new(defect_agent::session::CompositeRegistry::new(
738            overlay, current,
739        ));
740    }
741    for registry in registries {
742        current = Arc::new(defect_agent::session::CompositeRegistry::new(
743            registry.clone(),
744            current,
745        ));
746    }
747    current
748}
749
750fn resolve_base_prompt_text(config: &LoadedConfig) -> anyhow::Result<Option<String>> {
751    let base_prompt = &config.effective.base_prompt;
752    let mut sections = Vec::new();
753    if let Some(file) = base_prompt.file.as_deref() {
754        let text = std::fs::read_to_string(file)
755            .map_err(|e| anyhow::anyhow!("base_prompt file {} read failed: {e}", file.display()))?;
756        sections.push(text);
757    }
758    if let Some(text) = base_prompt.text.as_deref() {
759        sections.push(text.to_owned());
760    }
761    Ok((!sections.is_empty()).then(|| sections.join("\n\n")))
762}