Skip to main content

zag_agent/
builder.rs

1//! High-level builder API for driving agents programmatically.
2//!
3//! Instead of shelling out to the `agent` CLI binary, Rust programs can
4//! use `AgentBuilder` to configure and execute agent sessions directly.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use zag_agent::builder::AgentBuilder;
10//!
11//! # async fn example() -> anyhow::Result<()> {
12//! // Non-interactive exec — returns structured output
13//! let output = AgentBuilder::new()
14//!     .provider("claude")
15//!     .model("sonnet")
16//!     .auto_approve(true)
17//!     .exec("write a hello world program")
18//!     .await?;
19//!
20//! println!("{}", output.result.unwrap_or_default());
21//!
22//! // Interactive session
23//! AgentBuilder::new()
24//!     .provider("claude")
25//!     .run(Some("initial prompt"))
26//!     .await?;
27//! # Ok(())
28//! # }
29//! ```
30
31use 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
47/// Format a Duration as a human-readable string (e.g., "5m", "1h30m").
48fn 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
66/// Builder for configuring and running agent sessions.
67///
68/// Use the builder pattern to set options, then call a terminal method
69/// (`exec`, `run`, `resume`, `continue_last`) to execute.
70pub struct AgentBuilder {
71    provider: Option<String>,
72    /// Set to true when the caller explicitly pinned a provider via
73    /// `.provider()`. When false (default), the fallback tier list is
74    /// allowed to downgrade to the next provider on binary/probe failure.
75    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    /// Create a new builder with default settings.
110    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    /// Set the provider (e.g., "claude", "codex", "gemini", "copilot", "ollama").
142    ///
143    /// Calling this method pins the provider — it will NOT be downgraded to
144    /// another provider in the tier list if its binary is missing or the
145    /// startup probe fails. Omit this call (or set `provider` via the config
146    /// file) to allow automatic downgrading.
147    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    /// Set the model (e.g., "sonnet", "opus", "small", "large").
154    pub fn model(mut self, model: &str) -> Self {
155        self.model = Some(model.to_string());
156        self
157    }
158
159    /// Set a system prompt to configure agent behavior.
160    pub fn system_prompt(mut self, prompt: &str) -> Self {
161        self.system_prompt = Some(prompt.to_string());
162        self
163    }
164
165    /// Set the root directory for the agent to operate in.
166    pub fn root(mut self, root: &str) -> Self {
167        self.root = Some(root.to_string());
168        self
169    }
170
171    /// Enable auto-approve mode (skip permission prompts).
172    pub fn auto_approve(mut self, approve: bool) -> Self {
173        self.auto_approve = approve;
174        self
175    }
176
177    /// Add an additional directory for the agent to include.
178    pub fn add_dir(mut self, dir: &str) -> Self {
179        self.add_dirs.push(dir.to_string());
180        self
181    }
182
183    /// Attach a file to the prompt (text files ≤50 KB inlined, others referenced).
184    pub fn file(mut self, path: &str) -> Self {
185        self.files.push(path.to_string());
186        self
187    }
188
189    /// Add an environment variable for the agent subprocess.
190    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    /// Enable worktree mode with an optional name.
196    pub fn worktree(mut self, name: Option<&str>) -> Self {
197        self.worktree = Some(name.map(String::from));
198        self
199    }
200
201    /// Enable sandbox mode with an optional name.
202    pub fn sandbox(mut self, name: Option<&str>) -> Self {
203        self.sandbox = Some(name.map(String::from));
204        self
205    }
206
207    /// Set the Ollama parameter size (e.g., "2b", "9b", "35b").
208    pub fn size(mut self, size: &str) -> Self {
209        self.size = Some(size.to_string());
210        self
211    }
212
213    /// Request JSON output from the agent.
214    pub fn json(mut self) -> Self {
215        self.json_mode = true;
216        self
217    }
218
219    /// Set a JSON schema for structured output validation.
220    /// Implies `json()`.
221    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    /// Set a specific session ID (UUID).
228    pub fn session_id(mut self, id: &str) -> Self {
229        self.session_id = Some(id.to_string());
230        self
231    }
232
233    /// Set the output format (e.g., "text", "json", "json-pretty", "stream-json").
234    pub fn output_format(mut self, format: &str) -> Self {
235        self.output_format = Some(format.to_string());
236        self
237    }
238
239    /// Set the input format (Claude only, e.g., "text", "stream-json").
240    ///
241    /// No-op for Codex, Gemini, Copilot, and Ollama. See `docs/providers.md`
242    /// for the full per-provider support matrix.
243    pub fn input_format(mut self, format: &str) -> Self {
244        self.input_format = Some(format.to_string());
245        self
246    }
247
248    /// Re-emit user messages from stdin on stdout (Claude only).
249    ///
250    /// Only works with `--input-format stream-json` and `--output-format stream-json`.
251    /// [`exec_streaming`](Self::exec_streaming) auto-enables this flag, so most
252    /// callers never need to set it manually. No-op for non-Claude providers.
253    pub fn replay_user_messages(mut self, replay: bool) -> Self {
254        self.replay_user_messages = replay;
255        self
256    }
257
258    /// Include partial message chunks in streaming output (Claude only).
259    ///
260    /// Only works with `--output-format stream-json`. Defaults to `false`.
261    ///
262    /// When `false` (the default), streaming surfaces one `assistant_message`
263    /// event per complete assistant turn. When `true`, the agent instead emits
264    /// a stream of token-level partial `assistant_message` chunks as the model
265    /// generates them — use this for responsive, token-by-token UIs over
266    /// [`exec_streaming`](Self::exec_streaming). No-op for non-Claude providers.
267    pub fn include_partial_messages(mut self, include: bool) -> Self {
268        self.include_partial_messages = include;
269        self
270    }
271
272    /// Enable verbose output.
273    pub fn verbose(mut self, v: bool) -> Self {
274        self.verbose = v;
275        self
276    }
277
278    /// Enable quiet mode (suppress all non-essential output).
279    pub fn quiet(mut self, q: bool) -> Self {
280        self.quiet = q;
281        self
282    }
283
284    /// Show token usage statistics.
285    pub fn show_usage(mut self, show: bool) -> Self {
286        self.show_usage = show;
287        self
288    }
289
290    /// Set the maximum number of agentic turns.
291    pub fn max_turns(mut self, turns: u32) -> Self {
292        self.max_turns = Some(turns);
293        self
294    }
295
296    /// Set a timeout for exec. If the agent doesn't complete within this
297    /// duration, it will be killed and an error returned.
298    pub fn timeout(mut self, duration: std::time::Duration) -> Self {
299        self.timeout = Some(duration);
300        self
301    }
302
303    /// Set MCP server config for this invocation (Claude only).
304    ///
305    /// Accepts either a JSON string (`{"mcpServers": {...}}`) or a path to a JSON file.
306    /// No-op for Codex, Gemini, Copilot, and Ollama — those providers manage
307    /// MCP configuration through their own CLIs or do not support it. See
308    /// `docs/providers.md` for the full per-provider support matrix.
309    pub fn mcp_config(mut self, config: &str) -> Self {
310        self.mcp_config = Some(config.to_string());
311        self
312    }
313
314    /// Set a custom progress handler for status reporting.
315    pub fn on_progress(mut self, handler: Box<dyn ProgressHandler>) -> Self {
316        self.progress = handler;
317        self
318    }
319
320    /// Resolve file attachments and prepend them to a prompt.
321    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    /// Resolve the effective provider name.
335    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    /// Create and configure the agent.
355    ///
356    /// Returns the constructed agent along with the provider name that
357    /// actually succeeded. When `provider_explicit` is false, the factory
358    /// may downgrade to another provider in the tier list, so the returned
359    /// provider can differ from the one passed in.
360    async fn create_agent(&self, provider: &str) -> Result<(Box<dyn Agent + Send + Sync>, String)> {
361        // Apply system_prompt config fallback
362        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        // Augment system prompt with JSON instructions for non-Claude agents
370        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        // Apply max_turns: explicit > config > none
413        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        // Set output format
423        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        // Configure Claude-specific options
433        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        // Configure Ollama-specific options
461        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        // Configure sandbox
472        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    /// Run the agent non-interactively and return structured output.
501    ///
502    /// This is the primary entry point for programmatic use.
503    pub async fn exec(self, prompt: &str) -> Result<AgentOutput> {
504        let provider = self.resolve_provider()?;
505        debug!("exec: provider={}", provider);
506
507        // Set up worktree if requested
508        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        // Prepend file attachments
530        let prompt_with_files = builder.prepend_files(prompt)?;
531
532        // Handle JSON mode with prompt wrapping for non-Claude agents
533        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        // Clean up
555        agent.cleanup().await?;
556
557        if let Some(output) = result {
558            // Validate JSON output if schema is provided
559            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            // Agent returned no structured output — create a minimal one
591            Ok(AgentOutput::from_text(&provider, ""))
592        }
593    }
594
595    /// Run the agent with streaming input and output (Claude only).
596    ///
597    /// Returns a [`StreamingSession`] that allows sending NDJSON messages to
598    /// the agent's stdin and reading events from stdout. Automatically
599    /// configures `--input-format stream-json`, `--output-format stream-json`,
600    /// and `--replay-user-messages`.
601    ///
602    /// # Default emission granularity
603    ///
604    /// By default `assistant_message` events are emitted **once per complete
605    /// assistant turn** — you get one event when the model finishes speaking,
606    /// not a stream of token chunks. For responsive, token-level UIs call
607    /// [`include_partial_messages(true)`](Self::include_partial_messages)
608    /// on the builder before `exec_streaming`; the session will then emit
609    /// partial `assistant_message` chunks as the model generates them.
610    ///
611    /// The default is kept `false` so existing callers that render whole-turn
612    /// bubbles are not broken. See `docs/providers.md` for the full
613    /// per-provider flag support matrix.
614    ///
615    /// # Event lifecycle
616    ///
617    /// The session emits a unified
618    /// [`Event::Result`](crate::output::Event::Result) at the **end of every
619    /// agent turn** — not only at final session end. Use that event as the
620    /// authoritative turn-boundary signal. After a `Result`, the session
621    /// remains open and accepts another
622    /// [`send_user_message`](StreamingSession::send_user_message) for the next
623    /// turn. Call
624    /// [`close_input`](StreamingSession::close_input) followed by
625    /// [`wait`](StreamingSession::wait) to terminate the session cleanly.
626    ///
627    /// Do not depend on replayed `user_message` events to detect turn
628    /// boundaries; those only appear while `--replay-user-messages` is set.
629    ///
630    /// # Examples
631    ///
632    /// ```no_run
633    /// use zag_agent::builder::AgentBuilder;
634    /// use zag_agent::output::Event;
635    ///
636    /// # async fn example() -> anyhow::Result<()> {
637    /// let mut session = AgentBuilder::new()
638    ///     .provider("claude")
639    ///     .exec_streaming("initial prompt")
640    ///     .await?;
641    ///
642    /// // Drain the first turn until Result.
643    /// while let Some(event) = session.next_event().await? {
644    ///     println!("{:?}", event);
645    ///     if matches!(event, Event::Result { .. }) {
646    ///         break;
647    ///     }
648    /// }
649    ///
650    /// // Follow-up turn.
651    /// session.send_user_message("do something else").await?;
652    /// while let Some(event) = session.next_event().await? {
653    ///     if matches!(event, Event::Result { .. }) {
654    ///         break;
655    ///     }
656    /// }
657    ///
658    /// session.close_input();
659    /// session.wait().await?;
660    /// # Ok(())
661    /// # }
662    /// ```
663    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        // Prepend file attachments
672        let prompt_with_files = self.prepend_files(prompt)?;
673
674        // Streaming only works on Claude — do not allow the fallback loop
675        // to downgrade to a provider that can't stream.
676        let mut builder = self;
677        builder.provider_explicit = true;
678        let (agent, _provider) = builder.create_agent(&provider).await?;
679
680        // Downcast to Claude to call execute_streaming
681        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    /// Start an interactive agent session.
690    ///
691    /// This takes over stdin/stdout for the duration of the session.
692    pub async fn run(self, prompt: Option<&str>) -> Result<()> {
693        let provider = self.resolve_provider()?;
694        debug!("run: provider={}", provider);
695
696        // Prepend file attachments
697        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    /// Resume a previous session by ID.
717    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        // Resuming must stick with the recorded provider — no downgrade.
722        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    /// Resume the most recent session.
731    pub async fn continue_last(self) -> Result<()> {
732        let provider = self.resolve_provider()?;
733        debug!("continue_last: provider={}", provider);
734
735        // Resuming must stick with the recorded provider — no downgrade.
736        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;