1use crate::agent::Agent;
32use crate::attachment::{self, Attachment};
33use crate::config::Config;
34use crate::factory::AgentFactory;
35use crate::json_validation;
36use crate::output::AgentOutput;
37use crate::progress::{ProgressHandler, SilentProgress};
38use crate::providers::claude::Claude;
39use crate::providers::ollama::Ollama;
40use crate::sandbox::SandboxConfig;
41use crate::session::{SessionEntry, SessionStore};
42use crate::streaming::StreamingSession;
43use crate::worktree;
44use anyhow::{Result, bail};
45use log::{debug, warn};
46use std::time::Duration;
47
48fn format_duration(d: Duration) -> String {
50 let total_secs = d.as_secs();
51 let h = total_secs / 3600;
52 let m = (total_secs % 3600) / 60;
53 let s = total_secs % 60;
54 let mut parts = Vec::new();
55 if h > 0 {
56 parts.push(format!("{h}h"));
57 }
58 if m > 0 {
59 parts.push(format!("{m}m"));
60 }
61 if s > 0 || parts.is_empty() {
62 parts.push(format!("{s}s"));
63 }
64 parts.join("")
65}
66
67#[derive(Debug, Clone, Default)]
72pub struct SessionMetadata {
73 pub name: Option<String>,
74 pub description: Option<String>,
75 pub tags: Vec<String>,
76}
77
78pub struct AgentBuilder {
83 provider: Option<String>,
84 provider_explicit: bool,
88 model: Option<String>,
89 system_prompt: Option<String>,
90 root: Option<String>,
91 auto_approve: bool,
92 add_dirs: Vec<String>,
93 files: Vec<String>,
94 env_vars: Vec<(String, String)>,
95 worktree: Option<Option<String>>,
96 sandbox: Option<Option<String>>,
97 size: Option<String>,
98 json_mode: bool,
99 json_schema: Option<serde_json::Value>,
100 session_id: Option<String>,
101 metadata: SessionMetadata,
102 output_format: Option<String>,
103 input_format: Option<String>,
104 replay_user_messages: bool,
105 include_partial_messages: bool,
106 verbose: bool,
107 quiet: bool,
108 show_usage: bool,
109 max_turns: Option<u32>,
110 timeout: Option<std::time::Duration>,
111 mcp_config: Option<String>,
112 progress: Box<dyn ProgressHandler>,
113}
114
115impl Default for AgentBuilder {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl AgentBuilder {
122 pub fn new() -> Self {
124 Self {
125 provider: None,
126 provider_explicit: false,
127 model: None,
128 system_prompt: None,
129 root: None,
130 auto_approve: false,
131 add_dirs: Vec::new(),
132 files: Vec::new(),
133 env_vars: Vec::new(),
134 worktree: None,
135 sandbox: None,
136 size: None,
137 json_mode: false,
138 json_schema: None,
139 session_id: None,
140 metadata: SessionMetadata::default(),
141 output_format: None,
142 input_format: None,
143 replay_user_messages: false,
144 include_partial_messages: false,
145 verbose: false,
146 quiet: false,
147 show_usage: false,
148 max_turns: None,
149 timeout: None,
150 mcp_config: None,
151 progress: Box::new(SilentProgress),
152 }
153 }
154
155 pub fn provider(mut self, provider: &str) -> Self {
162 self.provider = Some(provider.to_string());
163 self.provider_explicit = true;
164 self
165 }
166
167 pub fn model(mut self, model: &str) -> Self {
169 self.model = Some(model.to_string());
170 self
171 }
172
173 pub fn system_prompt(mut self, prompt: &str) -> Self {
175 self.system_prompt = Some(prompt.to_string());
176 self
177 }
178
179 pub fn root(mut self, root: &str) -> Self {
181 self.root = Some(root.to_string());
182 self
183 }
184
185 pub fn auto_approve(mut self, approve: bool) -> Self {
187 self.auto_approve = approve;
188 self
189 }
190
191 pub fn add_dir(mut self, dir: &str) -> Self {
193 self.add_dirs.push(dir.to_string());
194 self
195 }
196
197 pub fn file(mut self, path: &str) -> Self {
199 self.files.push(path.to_string());
200 self
201 }
202
203 pub fn env(mut self, key: &str, value: &str) -> Self {
205 self.env_vars.push((key.to_string(), value.to_string()));
206 self
207 }
208
209 pub fn worktree(mut self, name: Option<&str>) -> Self {
211 self.worktree = Some(name.map(String::from));
212 self
213 }
214
215 pub fn sandbox(mut self, name: Option<&str>) -> Self {
217 self.sandbox = Some(name.map(String::from));
218 self
219 }
220
221 pub fn size(mut self, size: &str) -> Self {
223 self.size = Some(size.to_string());
224 self
225 }
226
227 pub fn json(mut self) -> Self {
229 self.json_mode = true;
230 self
231 }
232
233 pub fn json_schema(mut self, schema: serde_json::Value) -> Self {
236 self.json_schema = Some(schema);
237 self.json_mode = true;
238 self
239 }
240
241 pub fn session_id(mut self, id: &str) -> Self {
243 self.session_id = Some(id.to_string());
244 self
245 }
246
247 pub fn name(mut self, name: &str) -> Self {
254 self.metadata.name = Some(name.to_string());
255 self
256 }
257
258 pub fn description(mut self, description: &str) -> Self {
261 self.metadata.description = Some(description.to_string());
262 self
263 }
264
265 pub fn tag(mut self, tag: &str) -> Self {
268 self.metadata.tags.push(tag.to_string());
269 self
270 }
271
272 pub fn metadata(mut self, metadata: SessionMetadata) -> Self {
274 self.metadata = metadata;
275 self
276 }
277
278 pub fn output_format(mut self, format: &str) -> Self {
280 self.output_format = Some(format.to_string());
281 self
282 }
283
284 pub fn input_format(mut self, format: &str) -> Self {
289 self.input_format = Some(format.to_string());
290 self
291 }
292
293 pub fn replay_user_messages(mut self, replay: bool) -> Self {
299 self.replay_user_messages = replay;
300 self
301 }
302
303 pub fn include_partial_messages(mut self, include: bool) -> Self {
313 self.include_partial_messages = include;
314 self
315 }
316
317 pub fn verbose(mut self, v: bool) -> Self {
319 self.verbose = v;
320 self
321 }
322
323 pub fn quiet(mut self, q: bool) -> Self {
325 self.quiet = q;
326 self
327 }
328
329 pub fn show_usage(mut self, show: bool) -> Self {
331 self.show_usage = show;
332 self
333 }
334
335 pub fn max_turns(mut self, turns: u32) -> Self {
337 self.max_turns = Some(turns);
338 self
339 }
340
341 pub fn timeout(mut self, duration: std::time::Duration) -> Self {
344 self.timeout = Some(duration);
345 self
346 }
347
348 pub fn mcp_config(mut self, config: &str) -> Self {
355 self.mcp_config = Some(config.to_string());
356 self
357 }
358
359 pub fn on_progress(mut self, handler: Box<dyn ProgressHandler>) -> Self {
361 self.progress = handler;
362 self
363 }
364
365 fn persist_session_metadata(
375 &self,
376 provider: &str,
377 model: &str,
378 effective_root: Option<&str>,
379 ) -> Option<String> {
380 let has_metadata = self.metadata.name.is_some()
381 || self.metadata.description.is_some()
382 || !self.metadata.tags.is_empty();
383 if !has_metadata {
384 return None;
385 }
386
387 let session_id = self
388 .session_id
389 .clone()
390 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
391 let workspace_path = effective_root
392 .map(String::from)
393 .or_else(|| self.root.clone())
394 .unwrap_or_else(|| {
395 std::env::current_dir()
396 .map(|p| p.to_string_lossy().to_string())
397 .unwrap_or_default()
398 });
399
400 let entry = SessionEntry {
401 session_id: session_id.clone(),
402 provider: provider.to_string(),
403 model: model.to_string(),
404 worktree_path: workspace_path,
405 worktree_name: String::new(),
406 created_at: chrono::Utc::now().to_rfc3339(),
407 provider_session_id: None,
408 sandbox_name: None,
409 is_worktree: self.worktree.is_some(),
410 discovered: false,
411 discovery_source: None,
412 log_path: None,
413 log_completeness: "partial".to_string(),
414 name: self.metadata.name.clone(),
415 description: self.metadata.description.clone(),
416 tags: self.metadata.tags.clone(),
417 dependencies: Vec::new(),
418 retried_from: None,
419 interactive: false,
420 };
421
422 let mut store = SessionStore::load(self.root.as_deref()).unwrap_or_default();
423 store.add(entry);
424 if let Err(e) = store.save(self.root.as_deref()) {
425 warn!("Failed to persist session metadata: {e}");
426 }
427
428 Some(session_id)
429 }
430
431 fn prepend_files(&self, prompt: &str) -> Result<String> {
433 if self.files.is_empty() {
434 return Ok(prompt.to_string());
435 }
436 let attachments: Vec<Attachment> = self
437 .files
438 .iter()
439 .map(|f| Attachment::from_path(std::path::Path::new(f)))
440 .collect::<Result<Vec<_>>>()?;
441 let prefix = attachment::format_attachments_prefix(&attachments);
442 Ok(format!("{prefix}{prompt}"))
443 }
444
445 fn resolve_provider(&self) -> Result<String> {
447 if let Some(ref p) = self.provider {
448 let p = p.to_lowercase();
449 if !Config::VALID_PROVIDERS.contains(&p.as_str()) {
450 bail!(
451 "Invalid provider '{}'. Available: {}",
452 p,
453 Config::VALID_PROVIDERS.join(", ")
454 );
455 }
456 return Ok(p);
457 }
458 let config = Config::load(self.root.as_deref()).unwrap_or_default();
459 if let Some(p) = config.provider() {
460 return Ok(p.to_string());
461 }
462 Ok("claude".to_string())
463 }
464
465 async fn create_agent(&self, provider: &str) -> Result<(Box<dyn Agent + Send + Sync>, String)> {
472 let base_system_prompt = self.system_prompt.clone().or_else(|| {
474 Config::load(self.root.as_deref())
475 .unwrap_or_default()
476 .system_prompt()
477 .map(String::from)
478 });
479
480 let system_prompt = if self.json_mode && provider != "claude" {
482 let mut prompt = base_system_prompt.unwrap_or_default();
483 if let Some(ref schema) = self.json_schema {
484 let schema_str = serde_json::to_string_pretty(schema).unwrap_or_default();
485 prompt.push_str(&format!(
486 "\n\nYou MUST respond with valid JSON only. No markdown fences, no explanations. \
487 Your response must conform to this JSON schema:\n{schema_str}"
488 ));
489 } else {
490 prompt.push_str(
491 "\n\nYou MUST respond with valid JSON only. No markdown fences, no explanations.",
492 );
493 }
494 Some(prompt)
495 } else {
496 base_system_prompt
497 };
498
499 self.progress
500 .on_spinner_start(&format!("Initializing {provider} agent"));
501
502 let progress = &*self.progress;
503 let mut on_downgrade = |from: &str, to: &str, reason: &str| {
504 progress.on_warning(&format!("Downgrading provider: {from} → {to} ({reason})"));
505 };
506 let (mut agent, effective_provider) = AgentFactory::create_with_fallback(
507 provider,
508 self.provider_explicit,
509 system_prompt,
510 self.model.clone(),
511 self.root.clone(),
512 self.auto_approve,
513 self.add_dirs.clone(),
514 &mut on_downgrade,
515 )
516 .await?;
517 let provider = effective_provider.as_str();
518
519 let effective_max_turns = self.max_turns.or_else(|| {
521 Config::load(self.root.as_deref())
522 .unwrap_or_default()
523 .max_turns()
524 });
525 if let Some(turns) = effective_max_turns {
526 agent.set_max_turns(turns);
527 }
528
529 let mut output_format = self.output_format.clone();
531 if self.json_mode && output_format.is_none() {
532 output_format = Some("json".to_string());
533 if provider != "claude" {
534 agent.set_capture_output(true);
535 }
536 }
537 agent.set_output_format(output_format);
538
539 if provider == "claude"
541 && let Some(claude_agent) = agent.as_any_mut().downcast_mut::<Claude>()
542 {
543 claude_agent.set_verbose(self.verbose);
544 if let Some(ref session_id) = self.session_id {
545 claude_agent.set_session_id(session_id.clone());
546 }
547 if let Some(ref input_fmt) = self.input_format {
548 claude_agent.set_input_format(Some(input_fmt.clone()));
549 }
550 if self.replay_user_messages {
551 claude_agent.set_replay_user_messages(true);
552 }
553 if self.include_partial_messages {
554 claude_agent.set_include_partial_messages(true);
555 }
556 if self.json_mode
557 && let Some(ref schema) = self.json_schema
558 {
559 let schema_str = serde_json::to_string(schema).unwrap_or_default();
560 claude_agent.set_json_schema(Some(schema_str));
561 }
562 if self.mcp_config.is_some() {
563 claude_agent.set_mcp_config(self.mcp_config.clone());
564 }
565 }
566
567 if provider == "ollama"
569 && let Some(ollama_agent) = agent.as_any_mut().downcast_mut::<Ollama>()
570 {
571 let config = Config::load(self.root.as_deref()).unwrap_or_default();
572 if let Some(ref size) = self.size {
573 let resolved = config.ollama_size_for(size);
574 ollama_agent.set_size(resolved.to_string());
575 }
576 }
577
578 if let Some(ref sandbox_opt) = self.sandbox {
580 let sandbox_name = sandbox_opt
581 .as_deref()
582 .map(String::from)
583 .unwrap_or_else(crate::sandbox::generate_name);
584 let template = crate::sandbox::template_for_provider(provider);
585 let workspace = self.root.clone().unwrap_or_else(|| ".".to_string());
586 agent.set_sandbox(SandboxConfig {
587 name: sandbox_name,
588 template: template.to_string(),
589 workspace,
590 });
591 }
592
593 if !self.env_vars.is_empty() {
594 agent.set_env_vars(self.env_vars.clone());
595 }
596
597 self.progress.on_spinner_finish();
598 self.progress.on_success(&format!(
599 "{} initialized with model {}",
600 provider,
601 agent.get_model()
602 ));
603
604 Ok((agent, effective_provider))
605 }
606
607 pub async fn exec(self, prompt: &str) -> Result<AgentOutput> {
611 let provider = self.resolve_provider()?;
612 debug!("exec: provider={provider}");
613
614 let effective_root = if let Some(ref wt_opt) = self.worktree {
616 let wt_name = wt_opt
617 .as_deref()
618 .map(String::from)
619 .unwrap_or_else(worktree::generate_name);
620 let repo_root = worktree::git_repo_root(self.root.as_deref())?;
621 let wt_path = worktree::create_worktree(&repo_root, &wt_name)?;
622 self.progress
623 .on_success(&format!("Worktree created at {}", wt_path.display()));
624 Some(wt_path.to_string_lossy().to_string())
625 } else {
626 self.root.clone()
627 };
628
629 let mut builder = self;
630 if effective_root.is_some() {
631 builder.root = effective_root;
632 }
633
634 let (agent, provider) = builder.create_agent(&provider).await?;
635
636 let _ =
640 builder.persist_session_metadata(&provider, agent.get_model(), builder.root.as_deref());
641
642 let prompt_with_files = builder.prepend_files(prompt)?;
644
645 let effective_prompt = if builder.json_mode && provider != "claude" {
647 format!(
648 "IMPORTANT: You MUST respond with valid JSON only. No markdown, no explanation.\n\n{prompt_with_files}"
649 )
650 } else {
651 prompt_with_files
652 };
653
654 let result = if let Some(timeout_dur) = builder.timeout {
655 match tokio::time::timeout(timeout_dur, agent.run(Some(&effective_prompt))).await {
656 Ok(r) => r?,
657 Err(_) => {
658 agent.cleanup().await.ok();
659 bail!("Agent timed out after {}", format_duration(timeout_dur));
660 }
661 }
662 } else {
663 agent.run(Some(&effective_prompt)).await?
664 };
665
666 agent.cleanup().await?;
668
669 if let Some(output) = result {
670 if let Some(ref schema) = builder.json_schema {
672 if !builder.json_mode {
673 warn!(
674 "json_schema is set but json_mode is false — \
675 schema will not be sent to the agent, only used for output validation"
676 );
677 }
678 if let Some(ref result_text) = output.result {
679 debug!(
680 "exec: validating result ({} bytes): {:.300}",
681 result_text.len(),
682 result_text
683 );
684 if let Err(errors) = json_validation::validate_json_schema(result_text, schema)
685 {
686 let preview = if result_text.len() > 500 {
687 &result_text[..500]
688 } else {
689 result_text.as_str()
690 };
691 bail!(
692 "JSON schema validation failed: {}\nRaw agent output ({} bytes):\n{}",
693 errors.join("; "),
694 result_text.len(),
695 preview
696 );
697 }
698 }
699 }
700 Ok(output)
701 } else {
702 Ok(AgentOutput::from_text(&provider, ""))
704 }
705 }
706
707 pub async fn exec_streaming(self, prompt: &str) -> Result<StreamingSession> {
776 let provider = self.resolve_provider()?;
777 debug!("exec_streaming: provider={provider}");
778
779 if provider != "claude" {
780 bail!("Streaming input is only supported by the Claude provider");
781 }
782
783 let prompt_with_files = self.prepend_files(prompt)?;
785
786 let mut builder = self;
789 builder.provider_explicit = true;
790 let (agent, _provider) = builder.create_agent(&provider).await?;
791
792 let claude_agent = agent
794 .as_any_ref()
795 .downcast_ref::<Claude>()
796 .ok_or_else(|| anyhow::anyhow!("Failed to downcast agent to Claude"))?;
797
798 claude_agent.execute_streaming(Some(&prompt_with_files))
799 }
800
801 pub async fn run(self, prompt: Option<&str>) -> Result<()> {
805 let provider = self.resolve_provider()?;
806 debug!("run: provider={provider}");
807
808 let prompt_with_files = match prompt {
810 Some(p) => Some(self.prepend_files(p)?),
811 None if !self.files.is_empty() => {
812 let attachments: Vec<Attachment> = self
813 .files
814 .iter()
815 .map(|f| Attachment::from_path(std::path::Path::new(f)))
816 .collect::<Result<Vec<_>>>()?;
817 Some(attachment::format_attachments_prefix(&attachments))
818 }
819 None => None,
820 };
821
822 let (agent, effective_provider) = self.create_agent(&provider).await?;
823 let _ = self.persist_session_metadata(
824 &effective_provider,
825 agent.get_model(),
826 self.root.as_deref(),
827 );
828 agent.run_interactive(prompt_with_files.as_deref()).await?;
829 agent.cleanup().await?;
830 Ok(())
831 }
832
833 pub async fn resume(self, session_id: &str) -> Result<()> {
835 let provider = self.resolve_provider()?;
836 debug!("resume: provider={provider}, session={session_id}");
837
838 let mut builder = self;
840 builder.provider_explicit = true;
841 let (agent, _provider) = builder.create_agent(&provider).await?;
842 agent.run_resume(Some(session_id), false).await?;
843 agent.cleanup().await?;
844 Ok(())
845 }
846
847 pub async fn continue_last(self) -> Result<()> {
849 let provider = self.resolve_provider()?;
850 debug!("continue_last: provider={provider}");
851
852 let mut builder = self;
854 builder.provider_explicit = true;
855 let (agent, _provider) = builder.create_agent(&provider).await?;
856 agent.run_resume(None, true).await?;
857 agent.cleanup().await?;
858 Ok(())
859 }
860}
861
862#[cfg(test)]
863#[path = "builder_tests.rs"]
864mod tests;