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::{
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum DefaultFeature {
46 ProcessTools,
48 Subagents,
50 Skills,
52 Hooks,
54 Storage,
56 Mcp,
58 Observability,
60 Http,
62 Modes,
64}
65
66#[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 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 pub fn without(mut self, feature: DefaultFeature) -> Self {
115 self.set(feature, false);
116 self
117 }
118
119 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum ReplMode {
143 Disabled,
145 Enabled,
148}
149
150pub 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 pub goal: Option<Arc<defect_agent::session::GoalState>>,
163}
164
165pub 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: 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 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 pub fn features(mut self, features: DefaultFeatureSet) -> Self {
225 self.features = features;
226 self
227 }
228
229 pub fn repl(mut self, repl: ReplMode) -> Self {
231 self.repl = repl;
232 self
233 }
234
235 pub fn local_sessions(mut self) -> Self {
237 self.local_sessions = true;
238 self
239 }
240
241 pub fn profile(mut self, profile: impl Into<String>) -> Self {
243 self.profile = Some(profile.into());
244 self
245 }
246
247 pub fn resume(mut self, session_id: Option<String>) -> Self {
250 self.resume = Some(session_id);
251 self
252 }
253
254 pub fn provider_registry(mut self, registry: Arc<ProviderRegistry>) -> Self {
256 self.registry_override = Some(registry);
257 self
258 }
259
260 pub fn add_provider_entry(mut self, entry: ProviderEntry) -> Self {
262 self.extra_provider_entries.push(entry);
263 self
264 }
265
266 pub fn process_tools(mut self, tools: Arc<dyn ToolRegistry>) -> Self {
268 self.process_tools_override = Some(tools);
269 self
270 }
271
272 pub fn add_tool(mut self, tool: Arc<dyn Tool>) -> Self {
275 self.extra_process_tools.push(tool);
276 self
277 }
278
279 pub fn add_tool_registry(mut self, registry: Arc<dyn ToolRegistry>) -> Self {
282 self.extra_process_registries.push(registry);
283 self
284 }
285
286 pub fn policy(mut self, policy: Arc<dyn SandboxPolicy>) -> Self {
288 self.policy_override = Some(policy);
289 self
290 }
291
292 pub fn non_interactive(mut self) -> Self {
296 self.non_interactive = true;
297 self
298 }
299
300 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 pub fn max_turns(mut self, max_turns: u32) -> Self {
318 self.max_turns = Some(max_turns);
319 self
320 }
321
322 pub fn modes(mut self, modes: ModeCatalog) -> Self {
324 self.modes_override = Some(modes);
325 self
326 }
327
328 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 pub fn builtin_registry(mut self, registry: BuiltinRegistry) -> Self {
336 self.builtin_registry = registry;
337 self
338 }
339
340 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 pub fn observe_session(mut self, observer: Arc<dyn SessionObserver>) -> Self {
349 self.observers.push(observer);
350 self
351 }
352
353 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 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 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: ®istry,
410 default_model: turn_config.model.as_str(),
411 };
412
413 let mut process_tools = self.build_process_tools(
414 &profiles,
415 &skills,
416 ®istry,
417 &policy,
418 builtin_registry,
419 &hook_rt,
420 )?;
421 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 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}