1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DefaultFeature {
43 ProcessTools,
45 Subagents,
47 Skills,
49 Hooks,
51 Storage,
53 Mcp,
55 Observability,
57 Http,
59 Modes,
61}
62
63#[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 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 pub fn without(mut self, feature: DefaultFeature) -> Self {
112 self.set(feature, false);
113 self
114 }
115
116 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ReplMode {
140 Disabled,
142 Enabled,
145}
146
147pub 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 pub shell_output_max_bytes: usize,
158 pub goal: Option<Arc<defect_agent::session::GoalState>>,
165}
166
167pub 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: 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 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 pub fn features(mut self, features: DefaultFeatureSet) -> Self {
227 self.features = features;
228 self
229 }
230
231 pub fn repl(mut self, repl: ReplMode) -> Self {
233 self.repl = repl;
234 self
235 }
236
237 pub fn local_sessions(mut self) -> Self {
239 self.local_sessions = true;
240 self
241 }
242
243 pub fn profile(mut self, profile: impl Into<String>) -> Self {
245 self.profile = Some(profile.into());
246 self
247 }
248
249 pub fn resume(mut self, session_id: Option<String>) -> Self {
252 self.resume = Some(session_id);
253 self
254 }
255
256 pub fn provider_registry(mut self, registry: Arc<ProviderRegistry>) -> Self {
258 self.registry_override = Some(registry);
259 self
260 }
261
262 pub fn add_provider_entry(mut self, entry: ProviderEntry) -> Self {
264 self.extra_provider_entries.push(entry);
265 self
266 }
267
268 pub fn process_tools(mut self, tools: Arc<dyn ToolRegistry>) -> Self {
270 self.process_tools_override = Some(tools);
271 self
272 }
273
274 pub fn add_tool(mut self, tool: Arc<dyn Tool>) -> Self {
277 self.extra_process_tools.push(tool);
278 self
279 }
280
281 pub fn add_tool_registry(mut self, registry: Arc<dyn ToolRegistry>) -> Self {
284 self.extra_process_registries.push(registry);
285 self
286 }
287
288 pub fn policy(mut self, policy: Arc<dyn SandboxPolicy>) -> Self {
290 self.policy_override = Some(policy);
291 self
292 }
293
294 pub fn non_interactive(mut self) -> Self {
298 self.non_interactive = true;
299 self
300 }
301
302 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 pub fn max_turns(mut self, max_turns: u32) -> Self {
320 self.max_turns = Some(max_turns);
321 self
322 }
323
324 pub fn modes(mut self, modes: ModeCatalog) -> Self {
326 self.modes_override = Some(modes);
327 self
328 }
329
330 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 pub fn builtin_registry(mut self, registry: BuiltinRegistry) -> Self {
338 self.builtin_registry = registry;
339 self
340 }
341
342 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 pub fn observe_session(mut self, observer: Arc<dyn SessionObserver>) -> Self {
351 self.observers.push(observer);
352 self
353 }
354
355 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 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 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: ®istry,
412 default_model: turn_config.model.as_str(),
413 };
414
415 let mut process_tools = self.build_process_tools(
416 &profiles,
417 &skills,
418 ®istry,
419 &policy,
420 builtin_registry,
421 &hook_rt,
422 )?;
423 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 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 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 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}