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::streaming::StreamingSession;
42use crate::worktree;
43use anyhow::{Result, bail};
44use log::{debug, warn};
45use std::time::Duration;
46
47fn format_duration(d: Duration) -> String {
49 let total_secs = d.as_secs();
50 let h = total_secs / 3600;
51 let m = (total_secs % 3600) / 60;
52 let s = total_secs % 60;
53 let mut parts = Vec::new();
54 if h > 0 {
55 parts.push(format!("{h}h"));
56 }
57 if m > 0 {
58 parts.push(format!("{m}m"));
59 }
60 if s > 0 || parts.is_empty() {
61 parts.push(format!("{s}s"));
62 }
63 parts.join("")
64}
65
66pub struct AgentBuilder {
71 provider: Option<String>,
72 provider_explicit: bool,
76 model: Option<String>,
77 system_prompt: Option<String>,
78 root: Option<String>,
79 auto_approve: bool,
80 add_dirs: Vec<String>,
81 files: Vec<String>,
82 env_vars: Vec<(String, String)>,
83 worktree: Option<Option<String>>,
84 sandbox: Option<Option<String>>,
85 size: Option<String>,
86 json_mode: bool,
87 json_schema: Option<serde_json::Value>,
88 session_id: Option<String>,
89 output_format: Option<String>,
90 input_format: Option<String>,
91 replay_user_messages: bool,
92 include_partial_messages: bool,
93 verbose: bool,
94 quiet: bool,
95 show_usage: bool,
96 max_turns: Option<u32>,
97 timeout: Option<std::time::Duration>,
98 mcp_config: Option<String>,
99 progress: Box<dyn ProgressHandler>,
100}
101
102impl Default for AgentBuilder {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl AgentBuilder {
109 pub fn new() -> Self {
111 Self {
112 provider: None,
113 provider_explicit: false,
114 model: None,
115 system_prompt: None,
116 root: None,
117 auto_approve: false,
118 add_dirs: Vec::new(),
119 files: Vec::new(),
120 env_vars: Vec::new(),
121 worktree: None,
122 sandbox: None,
123 size: None,
124 json_mode: false,
125 json_schema: None,
126 session_id: None,
127 output_format: None,
128 input_format: None,
129 replay_user_messages: false,
130 include_partial_messages: false,
131 verbose: false,
132 quiet: false,
133 show_usage: false,
134 max_turns: None,
135 timeout: None,
136 mcp_config: None,
137 progress: Box::new(SilentProgress),
138 }
139 }
140
141 pub fn provider(mut self, provider: &str) -> Self {
148 self.provider = Some(provider.to_string());
149 self.provider_explicit = true;
150 self
151 }
152
153 pub fn model(mut self, model: &str) -> Self {
155 self.model = Some(model.to_string());
156 self
157 }
158
159 pub fn system_prompt(mut self, prompt: &str) -> Self {
161 self.system_prompt = Some(prompt.to_string());
162 self
163 }
164
165 pub fn root(mut self, root: &str) -> Self {
167 self.root = Some(root.to_string());
168 self
169 }
170
171 pub fn auto_approve(mut self, approve: bool) -> Self {
173 self.auto_approve = approve;
174 self
175 }
176
177 pub fn add_dir(mut self, dir: &str) -> Self {
179 self.add_dirs.push(dir.to_string());
180 self
181 }
182
183 pub fn file(mut self, path: &str) -> Self {
185 self.files.push(path.to_string());
186 self
187 }
188
189 pub fn env(mut self, key: &str, value: &str) -> Self {
191 self.env_vars.push((key.to_string(), value.to_string()));
192 self
193 }
194
195 pub fn worktree(mut self, name: Option<&str>) -> Self {
197 self.worktree = Some(name.map(String::from));
198 self
199 }
200
201 pub fn sandbox(mut self, name: Option<&str>) -> Self {
203 self.sandbox = Some(name.map(String::from));
204 self
205 }
206
207 pub fn size(mut self, size: &str) -> Self {
209 self.size = Some(size.to_string());
210 self
211 }
212
213 pub fn json(mut self) -> Self {
215 self.json_mode = true;
216 self
217 }
218
219 pub fn json_schema(mut self, schema: serde_json::Value) -> Self {
222 self.json_schema = Some(schema);
223 self.json_mode = true;
224 self
225 }
226
227 pub fn session_id(mut self, id: &str) -> Self {
229 self.session_id = Some(id.to_string());
230 self
231 }
232
233 pub fn output_format(mut self, format: &str) -> Self {
235 self.output_format = Some(format.to_string());
236 self
237 }
238
239 pub fn input_format(mut self, format: &str) -> Self {
244 self.input_format = Some(format.to_string());
245 self
246 }
247
248 pub fn replay_user_messages(mut self, replay: bool) -> Self {
254 self.replay_user_messages = replay;
255 self
256 }
257
258 pub fn include_partial_messages(mut self, include: bool) -> Self {
268 self.include_partial_messages = include;
269 self
270 }
271
272 pub fn verbose(mut self, v: bool) -> Self {
274 self.verbose = v;
275 self
276 }
277
278 pub fn quiet(mut self, q: bool) -> Self {
280 self.quiet = q;
281 self
282 }
283
284 pub fn show_usage(mut self, show: bool) -> Self {
286 self.show_usage = show;
287 self
288 }
289
290 pub fn max_turns(mut self, turns: u32) -> Self {
292 self.max_turns = Some(turns);
293 self
294 }
295
296 pub fn timeout(mut self, duration: std::time::Duration) -> Self {
299 self.timeout = Some(duration);
300 self
301 }
302
303 pub fn mcp_config(mut self, config: &str) -> Self {
310 self.mcp_config = Some(config.to_string());
311 self
312 }
313
314 pub fn on_progress(mut self, handler: Box<dyn ProgressHandler>) -> Self {
316 self.progress = handler;
317 self
318 }
319
320 fn prepend_files(&self, prompt: &str) -> Result<String> {
322 if self.files.is_empty() {
323 return Ok(prompt.to_string());
324 }
325 let attachments: Vec<Attachment> = self
326 .files
327 .iter()
328 .map(|f| Attachment::from_path(std::path::Path::new(f)))
329 .collect::<Result<Vec<_>>>()?;
330 let prefix = attachment::format_attachments_prefix(&attachments);
331 Ok(format!("{}{}", prefix, prompt))
332 }
333
334 fn resolve_provider(&self) -> Result<String> {
336 if let Some(ref p) = self.provider {
337 let p = p.to_lowercase();
338 if !Config::VALID_PROVIDERS.contains(&p.as_str()) {
339 bail!(
340 "Invalid provider '{}'. Available: {}",
341 p,
342 Config::VALID_PROVIDERS.join(", ")
343 );
344 }
345 return Ok(p);
346 }
347 let config = Config::load(self.root.as_deref()).unwrap_or_default();
348 if let Some(p) = config.provider() {
349 return Ok(p.to_string());
350 }
351 Ok("claude".to_string())
352 }
353
354 async fn create_agent(&self, provider: &str) -> Result<(Box<dyn Agent + Send + Sync>, String)> {
361 let base_system_prompt = self.system_prompt.clone().or_else(|| {
363 Config::load(self.root.as_deref())
364 .unwrap_or_default()
365 .system_prompt()
366 .map(String::from)
367 });
368
369 let system_prompt = if self.json_mode && provider != "claude" {
371 let mut prompt = base_system_prompt.unwrap_or_default();
372 if let Some(ref schema) = self.json_schema {
373 let schema_str = serde_json::to_string_pretty(schema).unwrap_or_default();
374 prompt.push_str(&format!(
375 "\n\nYou MUST respond with valid JSON only. No markdown fences, no explanations. \
376 Your response must conform to this JSON schema:\n{}",
377 schema_str
378 ));
379 } else {
380 prompt.push_str(
381 "\n\nYou MUST respond with valid JSON only. No markdown fences, no explanations.",
382 );
383 }
384 Some(prompt)
385 } else {
386 base_system_prompt
387 };
388
389 self.progress
390 .on_spinner_start(&format!("Initializing {} agent", provider));
391
392 let progress = &*self.progress;
393 let mut on_downgrade = |from: &str, to: &str, reason: &str| {
394 progress.on_warning(&format!(
395 "Downgrading provider: {} → {} ({})",
396 from, to, reason
397 ));
398 };
399 let (mut agent, effective_provider) = AgentFactory::create_with_fallback(
400 provider,
401 self.provider_explicit,
402 system_prompt,
403 self.model.clone(),
404 self.root.clone(),
405 self.auto_approve,
406 self.add_dirs.clone(),
407 &mut on_downgrade,
408 )
409 .await?;
410 let provider = effective_provider.as_str();
411
412 let effective_max_turns = self.max_turns.or_else(|| {
414 Config::load(self.root.as_deref())
415 .unwrap_or_default()
416 .max_turns()
417 });
418 if let Some(turns) = effective_max_turns {
419 agent.set_max_turns(turns);
420 }
421
422 let mut output_format = self.output_format.clone();
424 if self.json_mode && output_format.is_none() {
425 output_format = Some("json".to_string());
426 if provider != "claude" {
427 agent.set_capture_output(true);
428 }
429 }
430 agent.set_output_format(output_format);
431
432 if provider == "claude"
434 && let Some(claude_agent) = agent.as_any_mut().downcast_mut::<Claude>()
435 {
436 claude_agent.set_verbose(self.verbose);
437 if let Some(ref session_id) = self.session_id {
438 claude_agent.set_session_id(session_id.clone());
439 }
440 if let Some(ref input_fmt) = self.input_format {
441 claude_agent.set_input_format(Some(input_fmt.clone()));
442 }
443 if self.replay_user_messages {
444 claude_agent.set_replay_user_messages(true);
445 }
446 if self.include_partial_messages {
447 claude_agent.set_include_partial_messages(true);
448 }
449 if self.json_mode
450 && let Some(ref schema) = self.json_schema
451 {
452 let schema_str = serde_json::to_string(schema).unwrap_or_default();
453 claude_agent.set_json_schema(Some(schema_str));
454 }
455 if self.mcp_config.is_some() {
456 claude_agent.set_mcp_config(self.mcp_config.clone());
457 }
458 }
459
460 if provider == "ollama"
462 && let Some(ollama_agent) = agent.as_any_mut().downcast_mut::<Ollama>()
463 {
464 let config = Config::load(self.root.as_deref()).unwrap_or_default();
465 if let Some(ref size) = self.size {
466 let resolved = config.ollama_size_for(size);
467 ollama_agent.set_size(resolved.to_string());
468 }
469 }
470
471 if let Some(ref sandbox_opt) = self.sandbox {
473 let sandbox_name = sandbox_opt
474 .as_deref()
475 .map(String::from)
476 .unwrap_or_else(crate::sandbox::generate_name);
477 let template = crate::sandbox::template_for_provider(provider);
478 let workspace = self.root.clone().unwrap_or_else(|| ".".to_string());
479 agent.set_sandbox(SandboxConfig {
480 name: sandbox_name,
481 template: template.to_string(),
482 workspace,
483 });
484 }
485
486 if !self.env_vars.is_empty() {
487 agent.set_env_vars(self.env_vars.clone());
488 }
489
490 self.progress.on_spinner_finish();
491 self.progress.on_success(&format!(
492 "{} initialized with model {}",
493 provider,
494 agent.get_model()
495 ));
496
497 Ok((agent, effective_provider))
498 }
499
500 pub async fn exec(self, prompt: &str) -> Result<AgentOutput> {
504 let provider = self.resolve_provider()?;
505 debug!("exec: provider={}", provider);
506
507 let effective_root = if let Some(ref wt_opt) = self.worktree {
509 let wt_name = wt_opt
510 .as_deref()
511 .map(String::from)
512 .unwrap_or_else(worktree::generate_name);
513 let repo_root = worktree::git_repo_root(self.root.as_deref())?;
514 let wt_path = worktree::create_worktree(&repo_root, &wt_name)?;
515 self.progress
516 .on_success(&format!("Worktree created at {}", wt_path.display()));
517 Some(wt_path.to_string_lossy().to_string())
518 } else {
519 self.root.clone()
520 };
521
522 let mut builder = self;
523 if effective_root.is_some() {
524 builder.root = effective_root;
525 }
526
527 let (agent, provider) = builder.create_agent(&provider).await?;
528
529 let prompt_with_files = builder.prepend_files(prompt)?;
531
532 let effective_prompt = if builder.json_mode && provider != "claude" {
534 format!(
535 "IMPORTANT: You MUST respond with valid JSON only. No markdown, no explanation.\n\n{}",
536 prompt_with_files
537 )
538 } else {
539 prompt_with_files
540 };
541
542 let result = if let Some(timeout_dur) = builder.timeout {
543 match tokio::time::timeout(timeout_dur, agent.run(Some(&effective_prompt))).await {
544 Ok(r) => r?,
545 Err(_) => {
546 agent.cleanup().await.ok();
547 bail!("Agent timed out after {}", format_duration(timeout_dur));
548 }
549 }
550 } else {
551 agent.run(Some(&effective_prompt)).await?
552 };
553
554 agent.cleanup().await?;
556
557 if let Some(output) = result {
558 if let Some(ref schema) = builder.json_schema {
560 if !builder.json_mode {
561 warn!(
562 "json_schema is set but json_mode is false — \
563 schema will not be sent to the agent, only used for output validation"
564 );
565 }
566 if let Some(ref result_text) = output.result {
567 debug!(
568 "exec: validating result ({} bytes): {:.300}",
569 result_text.len(),
570 result_text
571 );
572 if let Err(errors) = json_validation::validate_json_schema(result_text, schema)
573 {
574 let preview = if result_text.len() > 500 {
575 &result_text[..500]
576 } else {
577 result_text.as_str()
578 };
579 bail!(
580 "JSON schema validation failed: {}\nRaw agent output ({} bytes):\n{}",
581 errors.join("; "),
582 result_text.len(),
583 preview
584 );
585 }
586 }
587 }
588 Ok(output)
589 } else {
590 Ok(AgentOutput::from_text(&provider, ""))
592 }
593 }
594
595 pub async fn exec_streaming(self, prompt: &str) -> Result<StreamingSession> {
664 let provider = self.resolve_provider()?;
665 debug!("exec_streaming: provider={}", provider);
666
667 if provider != "claude" {
668 bail!("Streaming input is only supported by the Claude provider");
669 }
670
671 let prompt_with_files = self.prepend_files(prompt)?;
673
674 let mut builder = self;
677 builder.provider_explicit = true;
678 let (agent, _provider) = builder.create_agent(&provider).await?;
679
680 let claude_agent = agent
682 .as_any_ref()
683 .downcast_ref::<Claude>()
684 .ok_or_else(|| anyhow::anyhow!("Failed to downcast agent to Claude"))?;
685
686 claude_agent.execute_streaming(Some(&prompt_with_files))
687 }
688
689 pub async fn run(self, prompt: Option<&str>) -> Result<()> {
693 let provider = self.resolve_provider()?;
694 debug!("run: provider={}", provider);
695
696 let prompt_with_files = match prompt {
698 Some(p) => Some(self.prepend_files(p)?),
699 None if !self.files.is_empty() => {
700 let attachments: Vec<Attachment> = self
701 .files
702 .iter()
703 .map(|f| Attachment::from_path(std::path::Path::new(f)))
704 .collect::<Result<Vec<_>>>()?;
705 Some(attachment::format_attachments_prefix(&attachments))
706 }
707 None => None,
708 };
709
710 let (agent, _provider) = self.create_agent(&provider).await?;
711 agent.run_interactive(prompt_with_files.as_deref()).await?;
712 agent.cleanup().await?;
713 Ok(())
714 }
715
716 pub async fn resume(self, session_id: &str) -> Result<()> {
718 let provider = self.resolve_provider()?;
719 debug!("resume: provider={}, session={}", provider, session_id);
720
721 let mut builder = self;
723 builder.provider_explicit = true;
724 let (agent, _provider) = builder.create_agent(&provider).await?;
725 agent.run_resume(Some(session_id), false).await?;
726 agent.cleanup().await?;
727 Ok(())
728 }
729
730 pub async fn continue_last(self) -> Result<()> {
732 let provider = self.resolve_provider()?;
733 debug!("continue_last: provider={}", provider);
734
735 let mut builder = self;
737 builder.provider_explicit = true;
738 let (agent, _provider) = builder.create_agent(&provider).await?;
739 agent.run_resume(None, true).await?;
740 agent.cleanup().await?;
741 Ok(())
742 }
743}
744
745#[cfg(test)]
746#[path = "builder_tests.rs"]
747mod tests;