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