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::session::{SessionEntry, SessionStore};
42use crate::streaming::StreamingSession;
43use crate::worktree;
44use anyhow::{Result, bail};
45use log::{debug, warn};
46use std::time::Duration;
47
48/// Format a Duration as a human-readable string (e.g., "5m", "1h30m").
49fn 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/// Session discovery metadata — mirrors the `--name`, `--description`, and
68/// `--tag` flags on the `run`/`exec`/`spawn` CLI commands. Attached to a
69/// builder via [`AgentBuilder::name`], [`AgentBuilder::description`], and
70/// [`AgentBuilder::tag`].
71#[derive(Debug, Clone, Default)]
72pub struct SessionMetadata {
73    pub name: Option<String>,
74    pub description: Option<String>,
75    pub tags: Vec<String>,
76}
77
78/// Builder for configuring and running agent sessions.
79///
80/// Use the builder pattern to set options, then call a terminal method
81/// (`exec`, `run`, `resume`, `continue_last`) to execute.
82pub struct AgentBuilder {
83    provider: Option<String>,
84    /// Set to true when the caller explicitly pinned a provider via
85    /// `.provider()`. When false (default), the fallback tier list is
86    /// allowed to downgrade to the next provider on binary/probe failure.
87    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    /// Create a new builder with default settings.
123    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    /// Set the provider (e.g., "claude", "codex", "gemini", "copilot", "ollama").
156    ///
157    /// Calling this method pins the provider — it will NOT be downgraded to
158    /// another provider in the tier list if its binary is missing or the
159    /// startup probe fails. Omit this call (or set `provider` via the config
160    /// file) to allow automatic downgrading.
161    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    /// Set the model (e.g., "sonnet", "opus", "small", "large").
168    pub fn model(mut self, model: &str) -> Self {
169        self.model = Some(model.to_string());
170        self
171    }
172
173    /// Set a system prompt to configure agent behavior.
174    pub fn system_prompt(mut self, prompt: &str) -> Self {
175        self.system_prompt = Some(prompt.to_string());
176        self
177    }
178
179    /// Set the root directory for the agent to operate in.
180    pub fn root(mut self, root: &str) -> Self {
181        self.root = Some(root.to_string());
182        self
183    }
184
185    /// Enable auto-approve mode (skip permission prompts).
186    pub fn auto_approve(mut self, approve: bool) -> Self {
187        self.auto_approve = approve;
188        self
189    }
190
191    /// Add an additional directory for the agent to include.
192    pub fn add_dir(mut self, dir: &str) -> Self {
193        self.add_dirs.push(dir.to_string());
194        self
195    }
196
197    /// Attach a file to the prompt (text files ≤50 KB inlined, others referenced).
198    pub fn file(mut self, path: &str) -> Self {
199        self.files.push(path.to_string());
200        self
201    }
202
203    /// Add an environment variable for the agent subprocess.
204    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    /// Enable worktree mode with an optional name.
210    pub fn worktree(mut self, name: Option<&str>) -> Self {
211        self.worktree = Some(name.map(String::from));
212        self
213    }
214
215    /// Enable sandbox mode with an optional name.
216    pub fn sandbox(mut self, name: Option<&str>) -> Self {
217        self.sandbox = Some(name.map(String::from));
218        self
219    }
220
221    /// Set the Ollama parameter size (e.g., "2b", "9b", "35b").
222    pub fn size(mut self, size: &str) -> Self {
223        self.size = Some(size.to_string());
224        self
225    }
226
227    /// Request JSON output from the agent.
228    pub fn json(mut self) -> Self {
229        self.json_mode = true;
230        self
231    }
232
233    /// Set a JSON schema for structured output validation.
234    /// Implies `json()`.
235    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    /// Set a specific session ID (UUID).
242    pub fn session_id(mut self, id: &str) -> Self {
243        self.session_id = Some(id.to_string());
244        self
245    }
246
247    /// Set a human-readable session name (mirrors the CLI's `--name`).
248    ///
249    /// Names are used by `zag input --name <n>`, `zag session list --name
250    /// <n>`, and for session discovery across the store. When the session
251    /// has a generated wrapper ID, the builder will persist this name to
252    /// the session store so CLI tools can find it later.
253    pub fn name(mut self, name: &str) -> Self {
254        self.metadata.name = Some(name.to_string());
255        self
256    }
257
258    /// Set a short description for the session (mirrors the CLI's
259    /// `--description`).
260    pub fn description(mut self, description: &str) -> Self {
261        self.metadata.description = Some(description.to_string());
262        self
263    }
264
265    /// Add a discovery tag for the session (mirrors the CLI's `--tag`,
266    /// repeatable).
267    pub fn tag(mut self, tag: &str) -> Self {
268        self.metadata.tags.push(tag.to_string());
269        self
270    }
271
272    /// Replace the full session metadata in one call.
273    pub fn metadata(mut self, metadata: SessionMetadata) -> Self {
274        self.metadata = metadata;
275        self
276    }
277
278    /// Set the output format (e.g., "text", "json", "json-pretty", "stream-json").
279    pub fn output_format(mut self, format: &str) -> Self {
280        self.output_format = Some(format.to_string());
281        self
282    }
283
284    /// Set the input format (Claude only, e.g., "text", "stream-json").
285    ///
286    /// No-op for Codex, Gemini, Copilot, and Ollama. See `docs/providers.md`
287    /// for the full per-provider support matrix.
288    pub fn input_format(mut self, format: &str) -> Self {
289        self.input_format = Some(format.to_string());
290        self
291    }
292
293    /// Re-emit user messages from stdin on stdout (Claude only).
294    ///
295    /// Only works with `--input-format stream-json` and `--output-format stream-json`.
296    /// [`exec_streaming`](Self::exec_streaming) auto-enables this flag, so most
297    /// callers never need to set it manually. No-op for non-Claude providers.
298    pub fn replay_user_messages(mut self, replay: bool) -> Self {
299        self.replay_user_messages = replay;
300        self
301    }
302
303    /// Include partial message chunks in streaming output (Claude only).
304    ///
305    /// Only works with `--output-format stream-json`. Defaults to `false`.
306    ///
307    /// When `false` (the default), streaming surfaces one `assistant_message`
308    /// event per complete assistant turn. When `true`, the agent instead emits
309    /// a stream of token-level partial `assistant_message` chunks as the model
310    /// generates them — use this for responsive, token-by-token UIs over
311    /// [`exec_streaming`](Self::exec_streaming). No-op for non-Claude providers.
312    pub fn include_partial_messages(mut self, include: bool) -> Self {
313        self.include_partial_messages = include;
314        self
315    }
316
317    /// Enable verbose output.
318    pub fn verbose(mut self, v: bool) -> Self {
319        self.verbose = v;
320        self
321    }
322
323    /// Enable quiet mode (suppress all non-essential output).
324    pub fn quiet(mut self, q: bool) -> Self {
325        self.quiet = q;
326        self
327    }
328
329    /// Show token usage statistics.
330    pub fn show_usage(mut self, show: bool) -> Self {
331        self.show_usage = show;
332        self
333    }
334
335    /// Set the maximum number of agentic turns.
336    pub fn max_turns(mut self, turns: u32) -> Self {
337        self.max_turns = Some(turns);
338        self
339    }
340
341    /// Set a timeout for exec. If the agent doesn't complete within this
342    /// duration, it will be killed and an error returned.
343    pub fn timeout(mut self, duration: std::time::Duration) -> Self {
344        self.timeout = Some(duration);
345        self
346    }
347
348    /// Set MCP server config for this invocation (Claude only).
349    ///
350    /// Accepts either a JSON string (`{"mcpServers": {...}}`) or a path to a JSON file.
351    /// No-op for Codex, Gemini, Copilot, and Ollama — those providers manage
352    /// MCP configuration through their own CLIs or do not support it. See
353    /// `docs/providers.md` for the full per-provider support matrix.
354    pub fn mcp_config(mut self, config: &str) -> Self {
355        self.mcp_config = Some(config.to_string());
356        self
357    }
358
359    /// Set a custom progress handler for status reporting.
360    pub fn on_progress(mut self, handler: Box<dyn ProgressHandler>) -> Self {
361        self.progress = handler;
362        self
363    }
364
365    /// Persist a `SessionEntry` to the session store so `zag session list`
366    /// and `zag input --name <n>` can discover this builder-spawned session.
367    ///
368    /// No-op when no metadata is set — callers who don't name their sessions
369    /// still get the old behavior of not leaving a trail in the session store.
370    ///
371    /// Returns the session ID that was persisted (either the caller-provided
372    /// one or a freshly generated UUID), so downstream logging can reference
373    /// the same ID.
374    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    /// Resolve file attachments and prepend them to a prompt.
432    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    /// Resolve the effective provider name.
446    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    /// Create and configure the agent.
466    ///
467    /// Returns the constructed agent along with the provider name that
468    /// actually succeeded. When `provider_explicit` is false, the factory
469    /// may downgrade to another provider in the tier list, so the returned
470    /// provider can differ from the one passed in.
471    async fn create_agent(&self, provider: &str) -> Result<(Box<dyn Agent + Send + Sync>, String)> {
472        // Apply system_prompt config fallback
473        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        // Augment system prompt with JSON instructions for non-Claude agents
481        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        // Apply max_turns: explicit > config > none
520        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        // Set output format
530        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        // Configure Claude-specific options
540        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        // Configure Ollama-specific options
568        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        // Configure sandbox
579        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    /// Run the agent non-interactively and return structured output.
608    ///
609    /// This is the primary entry point for programmatic use.
610    pub async fn exec(self, prompt: &str) -> Result<AgentOutput> {
611        let provider = self.resolve_provider()?;
612        debug!("exec: provider={provider}");
613
614        // Set up worktree if requested
615        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        // Persist the session entry so discovery (session list --name, input
637        // --name) works for builder-spawned sessions. No-op when no metadata
638        // is set.
639        let _ =
640            builder.persist_session_metadata(&provider, agent.get_model(), builder.root.as_deref());
641
642        // Prepend file attachments
643        let prompt_with_files = builder.prepend_files(prompt)?;
644
645        // Handle JSON mode with prompt wrapping for non-Claude agents
646        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        // Clean up
667        agent.cleanup().await?;
668
669        if let Some(output) = result {
670            // Validate JSON output if schema is provided
671            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            // Agent returned no structured output — create a minimal one
703            Ok(AgentOutput::from_text(&provider, ""))
704        }
705    }
706
707    /// Run the agent with streaming input and output (Claude only).
708    ///
709    /// Returns a [`StreamingSession`] that allows sending NDJSON messages to
710    /// the agent's stdin and reading events from stdout. Automatically
711    /// configures `--input-format stream-json`, `--output-format stream-json`,
712    /// and `--replay-user-messages`.
713    ///
714    /// # Default emission granularity
715    ///
716    /// By default `assistant_message` events are emitted **once per complete
717    /// assistant turn** — you get one event when the model finishes speaking,
718    /// not a stream of token chunks. For responsive, token-level UIs call
719    /// [`include_partial_messages(true)`](Self::include_partial_messages)
720    /// on the builder before `exec_streaming`; the session will then emit
721    /// partial `assistant_message` chunks as the model generates them.
722    ///
723    /// The default is kept `false` so existing callers that render whole-turn
724    /// bubbles are not broken. See `docs/providers.md` for the full
725    /// per-provider flag support matrix.
726    ///
727    /// # Event lifecycle
728    ///
729    /// The session emits a unified
730    /// [`Event::Result`](crate::output::Event::Result) at the **end of every
731    /// agent turn** — not only at final session end. Use that event as the
732    /// authoritative turn-boundary signal. After a `Result`, the session
733    /// remains open and accepts another
734    /// [`send_user_message`](StreamingSession::send_user_message) for the next
735    /// turn. Call
736    /// [`close_input`](StreamingSession::close_input) followed by
737    /// [`wait`](StreamingSession::wait) to terminate the session cleanly.
738    ///
739    /// Do not depend on replayed `user_message` events to detect turn
740    /// boundaries; those only appear while `--replay-user-messages` is set.
741    ///
742    /// # Examples
743    ///
744    /// ```no_run
745    /// use zag_agent::builder::AgentBuilder;
746    /// use zag_agent::output::Event;
747    ///
748    /// # async fn example() -> anyhow::Result<()> {
749    /// let mut session = AgentBuilder::new()
750    ///     .provider("claude")
751    ///     .exec_streaming("initial prompt")
752    ///     .await?;
753    ///
754    /// // Drain the first turn until Result.
755    /// while let Some(event) = session.next_event().await? {
756    ///     println!("{:?}", event);
757    ///     if matches!(event, Event::Result { .. }) {
758    ///         break;
759    ///     }
760    /// }
761    ///
762    /// // Follow-up turn.
763    /// session.send_user_message("do something else").await?;
764    /// while let Some(event) = session.next_event().await? {
765    ///     if matches!(event, Event::Result { .. }) {
766    ///         break;
767    ///     }
768    /// }
769    ///
770    /// session.close_input();
771    /// session.wait().await?;
772    /// # Ok(())
773    /// # }
774    /// ```
775    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        // Prepend file attachments
784        let prompt_with_files = self.prepend_files(prompt)?;
785
786        // Streaming only works on Claude — do not allow the fallback loop
787        // to downgrade to a provider that can't stream.
788        let mut builder = self;
789        builder.provider_explicit = true;
790        let (agent, _provider) = builder.create_agent(&provider).await?;
791
792        // Downcast to Claude to call execute_streaming
793        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    /// Start an interactive agent session.
802    ///
803    /// This takes over stdin/stdout for the duration of the session.
804    pub async fn run(self, prompt: Option<&str>) -> Result<()> {
805        let provider = self.resolve_provider()?;
806        debug!("run: provider={provider}");
807
808        // Prepend file attachments
809        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    /// Resume a previous session by ID.
834    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        // Resuming must stick with the recorded provider — no downgrade.
839        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    /// Resume the most recent session.
848    pub async fn continue_last(self) -> Result<()> {
849        let provider = self.resolve_provider()?;
850        debug!("continue_last: provider={provider}");
851
852        // Resuming must stick with the recorded provider — no downgrade.
853        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;