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{schema_str}"
377 ));
378 } else {
379 prompt.push_str(
380 "\n\nYou MUST respond with valid JSON only. No markdown fences, no explanations.",
381 );
382 }
383 Some(prompt)
384 } else {
385 base_system_prompt
386 };
387
388 self.progress
389 .on_spinner_start(&format!("Initializing {provider} agent"));
390
391 let progress = &*self.progress;
392 let mut on_downgrade = |from: &str, to: &str, reason: &str| {
393 progress.on_warning(&format!("Downgrading provider: {from} → {to} ({reason})"));
394 };
395 let (mut agent, effective_provider) = AgentFactory::create_with_fallback(
396 provider,
397 self.provider_explicit,
398 system_prompt,
399 self.model.clone(),
400 self.root.clone(),
401 self.auto_approve,
402 self.add_dirs.clone(),
403 &mut on_downgrade,
404 )
405 .await?;
406 let provider = effective_provider.as_str();
407
408 let effective_max_turns = self.max_turns.or_else(|| {
410 Config::load(self.root.as_deref())
411 .unwrap_or_default()
412 .max_turns()
413 });
414 if let Some(turns) = effective_max_turns {
415 agent.set_max_turns(turns);
416 }
417
418 let mut output_format = self.output_format.clone();
420 if self.json_mode && output_format.is_none() {
421 output_format = Some("json".to_string());
422 if provider != "claude" {
423 agent.set_capture_output(true);
424 }
425 }
426 agent.set_output_format(output_format);
427
428 if provider == "claude"
430 && let Some(claude_agent) = agent.as_any_mut().downcast_mut::<Claude>()
431 {
432 claude_agent.set_verbose(self.verbose);
433 if let Some(ref session_id) = self.session_id {
434 claude_agent.set_session_id(session_id.clone());
435 }
436 if let Some(ref input_fmt) = self.input_format {
437 claude_agent.set_input_format(Some(input_fmt.clone()));
438 }
439 if self.replay_user_messages {
440 claude_agent.set_replay_user_messages(true);
441 }
442 if self.include_partial_messages {
443 claude_agent.set_include_partial_messages(true);
444 }
445 if self.json_mode
446 && let Some(ref schema) = self.json_schema
447 {
448 let schema_str = serde_json::to_string(schema).unwrap_or_default();
449 claude_agent.set_json_schema(Some(schema_str));
450 }
451 if self.mcp_config.is_some() {
452 claude_agent.set_mcp_config(self.mcp_config.clone());
453 }
454 }
455
456 if provider == "ollama"
458 && let Some(ollama_agent) = agent.as_any_mut().downcast_mut::<Ollama>()
459 {
460 let config = Config::load(self.root.as_deref()).unwrap_or_default();
461 if let Some(ref size) = self.size {
462 let resolved = config.ollama_size_for(size);
463 ollama_agent.set_size(resolved.to_string());
464 }
465 }
466
467 if let Some(ref sandbox_opt) = self.sandbox {
469 let sandbox_name = sandbox_opt
470 .as_deref()
471 .map(String::from)
472 .unwrap_or_else(crate::sandbox::generate_name);
473 let template = crate::sandbox::template_for_provider(provider);
474 let workspace = self.root.clone().unwrap_or_else(|| ".".to_string());
475 agent.set_sandbox(SandboxConfig {
476 name: sandbox_name,
477 template: template.to_string(),
478 workspace,
479 });
480 }
481
482 if !self.env_vars.is_empty() {
483 agent.set_env_vars(self.env_vars.clone());
484 }
485
486 self.progress.on_spinner_finish();
487 self.progress.on_success(&format!(
488 "{} initialized with model {}",
489 provider,
490 agent.get_model()
491 ));
492
493 Ok((agent, effective_provider))
494 }
495
496 pub async fn exec(self, prompt: &str) -> Result<AgentOutput> {
500 let provider = self.resolve_provider()?;
501 debug!("exec: provider={provider}");
502
503 let effective_root = if let Some(ref wt_opt) = self.worktree {
505 let wt_name = wt_opt
506 .as_deref()
507 .map(String::from)
508 .unwrap_or_else(worktree::generate_name);
509 let repo_root = worktree::git_repo_root(self.root.as_deref())?;
510 let wt_path = worktree::create_worktree(&repo_root, &wt_name)?;
511 self.progress
512 .on_success(&format!("Worktree created at {}", wt_path.display()));
513 Some(wt_path.to_string_lossy().to_string())
514 } else {
515 self.root.clone()
516 };
517
518 let mut builder = self;
519 if effective_root.is_some() {
520 builder.root = effective_root;
521 }
522
523 let (agent, provider) = builder.create_agent(&provider).await?;
524
525 let prompt_with_files = builder.prepend_files(prompt)?;
527
528 let effective_prompt = if builder.json_mode && provider != "claude" {
530 format!(
531 "IMPORTANT: You MUST respond with valid JSON only. No markdown, no explanation.\n\n{prompt_with_files}"
532 )
533 } else {
534 prompt_with_files
535 };
536
537 let result = if let Some(timeout_dur) = builder.timeout {
538 match tokio::time::timeout(timeout_dur, agent.run(Some(&effective_prompt))).await {
539 Ok(r) => r?,
540 Err(_) => {
541 agent.cleanup().await.ok();
542 bail!("Agent timed out after {}", format_duration(timeout_dur));
543 }
544 }
545 } else {
546 agent.run(Some(&effective_prompt)).await?
547 };
548
549 agent.cleanup().await?;
551
552 if let Some(output) = result {
553 if let Some(ref schema) = builder.json_schema {
555 if !builder.json_mode {
556 warn!(
557 "json_schema is set but json_mode is false — \
558 schema will not be sent to the agent, only used for output validation"
559 );
560 }
561 if let Some(ref result_text) = output.result {
562 debug!(
563 "exec: validating result ({} bytes): {:.300}",
564 result_text.len(),
565 result_text
566 );
567 if let Err(errors) = json_validation::validate_json_schema(result_text, schema)
568 {
569 let preview = if result_text.len() > 500 {
570 &result_text[..500]
571 } else {
572 result_text.as_str()
573 };
574 bail!(
575 "JSON schema validation failed: {}\nRaw agent output ({} bytes):\n{}",
576 errors.join("; "),
577 result_text.len(),
578 preview
579 );
580 }
581 }
582 }
583 Ok(output)
584 } else {
585 Ok(AgentOutput::from_text(&provider, ""))
587 }
588 }
589
590 pub async fn exec_streaming(self, prompt: &str) -> Result<StreamingSession> {
659 let provider = self.resolve_provider()?;
660 debug!("exec_streaming: provider={provider}");
661
662 if provider != "claude" {
663 bail!("Streaming input is only supported by the Claude provider");
664 }
665
666 let prompt_with_files = self.prepend_files(prompt)?;
668
669 let mut builder = self;
672 builder.provider_explicit = true;
673 let (agent, _provider) = builder.create_agent(&provider).await?;
674
675 let claude_agent = agent
677 .as_any_ref()
678 .downcast_ref::<Claude>()
679 .ok_or_else(|| anyhow::anyhow!("Failed to downcast agent to Claude"))?;
680
681 claude_agent.execute_streaming(Some(&prompt_with_files))
682 }
683
684 pub async fn run(self, prompt: Option<&str>) -> Result<()> {
688 let provider = self.resolve_provider()?;
689 debug!("run: provider={provider}");
690
691 let prompt_with_files = match prompt {
693 Some(p) => Some(self.prepend_files(p)?),
694 None if !self.files.is_empty() => {
695 let attachments: Vec<Attachment> = self
696 .files
697 .iter()
698 .map(|f| Attachment::from_path(std::path::Path::new(f)))
699 .collect::<Result<Vec<_>>>()?;
700 Some(attachment::format_attachments_prefix(&attachments))
701 }
702 None => None,
703 };
704
705 let (agent, _provider) = self.create_agent(&provider).await?;
706 agent.run_interactive(prompt_with_files.as_deref()).await?;
707 agent.cleanup().await?;
708 Ok(())
709 }
710
711 pub async fn resume(self, session_id: &str) -> Result<()> {
713 let provider = self.resolve_provider()?;
714 debug!("resume: provider={provider}, session={session_id}");
715
716 let mut builder = self;
718 builder.provider_explicit = true;
719 let (agent, _provider) = builder.create_agent(&provider).await?;
720 agent.run_resume(Some(session_id), false).await?;
721 agent.cleanup().await?;
722 Ok(())
723 }
724
725 pub async fn continue_last(self) -> Result<()> {
727 let provider = self.resolve_provider()?;
728 debug!("continue_last: provider={provider}");
729
730 let mut builder = self;
732 builder.provider_explicit = true;
733 let (agent, _provider) = builder.create_agent(&provider).await?;
734 agent.run_resume(None, true).await?;
735 agent.cleanup().await?;
736 Ok(())
737 }
738}
739
740#[cfg(test)]
741#[path = "builder_tests.rs"]
742mod tests;