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::listen::{self, ListenFormat};
37use crate::output::AgentOutput;
38use crate::process_registration::{self, ProcessRegistration, RegisterOptionsOwned};
39use crate::progress::{ProgressHandler, SilentProgress};
40use crate::providers::claude::Claude;
41use crate::providers::ollama::Ollama;
42use crate::sandbox::SandboxConfig;
43use crate::session::{SessionEntry, SessionStore};
44use crate::session_log::{
45    AgentLogEvent, LiveLogContext, LogEventCallback, SessionLogCoordinator, SessionLogMetadata,
46    live_adapter_for_provider, logs_dir,
47};
48use crate::streaming::StreamingSession;
49use crate::worktree;
50use anyhow::{Result, bail};
51use log::{debug, warn};
52use std::sync::Arc;
53use std::time::Duration;
54
55/// Format a Duration as a human-readable string (e.g., "5m", "1h30m").
56fn format_duration(d: Duration) -> String {
57    let total_secs = d.as_secs();
58    let h = total_secs / 3600;
59    let m = (total_secs % 3600) / 60;
60    let s = total_secs % 60;
61    let mut parts = Vec::new();
62    if h > 0 {
63        parts.push(format!("{h}h"));
64    }
65    if m > 0 {
66        parts.push(format!("{m}m"));
67    }
68    if s > 0 || parts.is_empty() {
69        parts.push(format!("{s}s"));
70    }
71    parts.join("")
72}
73
74/// Session discovery metadata — mirrors the `--name`, `--description`, and
75/// `--tag` flags on the `run`/`exec`/`spawn` CLI commands. Attached to a
76/// builder via [`AgentBuilder::name`], [`AgentBuilder::description`], and
77/// [`AgentBuilder::tag`].
78#[derive(Debug, Clone, Default)]
79pub struct SessionMetadata {
80    pub name: Option<String>,
81    pub description: Option<String>,
82    pub tags: Vec<String>,
83}
84
85/// Private guard returned by `AgentBuilder::start_session_log` — owns
86/// the coordinator (when `Auto`) or defers ownership to the caller (when
87/// `External`). Dropping the guard implicitly finalises the owned
88/// coordinator via its own `Drop` impl.
89struct SessionLogGuard {
90    /// Set when the builder started its own coordinator (`Auto`). Dropped
91    /// at the end of the terminal method, which finalises the log.
92    coordinator: Option<SessionLogCoordinator>,
93    wrapper_session_id: String,
94    log_path: Option<std::path::PathBuf>,
95    /// When the caller supplied an `External` coordinator, we keep a
96    /// writer clone so that `clear_event_callback` still works on exit.
97    external_writer: Option<crate::session_log::SessionLogWriter>,
98    /// Holds the externally-owned coordinator until the guard drops so
99    /// callers who pass `SessionLogMode::External` don't have to keep
100    /// their own handle alive. (They can; this is just a convenience.)
101    _owned_external: Option<SessionLogCoordinator>,
102}
103
104impl SessionLogGuard {
105    fn log_path_string(&self) -> Option<String> {
106        self.log_path
107            .as_ref()
108            .map(|p| p.to_string_lossy().to_string())
109    }
110
111    /// Flush the coordinator (emit `SessionEnded`, tear down the heartbeat /
112    /// live-adapter task). For `External` mode the caller retains ownership
113    /// — we merely detach our event callback so it stops firing after the
114    /// terminal method returns.
115    async fn finish(mut self, success: bool, error: Option<String>) {
116        // Run the finalization *before* detaching the callback so that the
117        // closing `SessionEnded` event still fires through the user's hook.
118        if let Some(coord) = self.coordinator.take() {
119            if let Err(e) = coord.finish(success, error).await {
120                warn!("Failed to finalize session log: {e}");
121            }
122        }
123        if let Some(w) = self.external_writer.take() {
124            let _ = w.clear_event_callback();
125        }
126    }
127}
128
129impl Drop for SessionLogGuard {
130    fn drop(&mut self) {
131        // Drop-path fallback: if a terminal method panicked or returned
132        // early without calling `finish`, still detach callbacks so user
133        // code stops receiving events. The owned coordinator's own `Drop`
134        // will kill the background task even without an explicit finish.
135        if let Some(ref w) = self.external_writer {
136            let _ = w.clear_event_callback();
137        }
138        if let Some(ref c) = self.coordinator {
139            let _ = c.writer().clear_event_callback();
140        }
141    }
142}
143
144/// Controls whether the builder manages a [`SessionLogCoordinator`] for the
145/// session it launches.
146///
147/// Default for [`AgentBuilder`] is [`SessionLogMode::Disabled`] so that
148/// existing Rust library callers see no side effects. The CLI and any
149/// caller that wants live event streaming should select
150/// [`SessionLogMode::Auto`].
151///
152/// [`SessionLogMode::External`] lets an advanced caller (e.g. the CLI's
153/// plan/review handlers, `zag-serve`) start and bookkeep its own
154/// coordinator — the builder will write through it without double-starting.
155#[derive(Default)]
156pub enum SessionLogMode {
157    /// No session log is started by the builder. No on-disk JSONL and no
158    /// live event callbacks.
159    #[default]
160    Disabled,
161    /// The builder starts its own [`SessionLogCoordinator`], tears it down
162    /// when the terminal method returns, and populates
163    /// [`AgentOutput::log_path`].
164    Auto,
165    /// The caller provides a pre-started [`SessionLogCoordinator`]; the
166    /// builder uses it verbatim and does not stop it at exit.
167    External(SessionLogCoordinator),
168}
169
170/// Builder for configuring and running agent sessions.
171///
172/// Use the builder pattern to set options, then call a terminal method
173/// (`exec`, `run`, `resume`, `continue_last`) to execute.
174pub struct AgentBuilder {
175    provider: Option<String>,
176    /// Set to true when the caller explicitly pinned a provider via
177    /// `.provider()`. When false (default), the fallback tier list is
178    /// allowed to downgrade to the next provider on binary/probe failure.
179    provider_explicit: bool,
180    model: Option<String>,
181    system_prompt: Option<String>,
182    root: Option<String>,
183    auto_approve: bool,
184    add_dirs: Vec<String>,
185    files: Vec<String>,
186    env_vars: Vec<(String, String)>,
187    worktree: Option<Option<String>>,
188    sandbox: Option<Option<String>>,
189    size: Option<String>,
190    json_mode: bool,
191    json_schema: Option<serde_json::Value>,
192    session_id: Option<String>,
193    metadata: SessionMetadata,
194    output_format: Option<String>,
195    input_format: Option<String>,
196    replay_user_messages: bool,
197    include_partial_messages: bool,
198    verbose: bool,
199    quiet: bool,
200    show_usage: bool,
201    max_turns: Option<u32>,
202    timeout: Option<std::time::Duration>,
203    mcp_config: Option<String>,
204    progress: Box<dyn ProgressHandler>,
205    session_log_mode: SessionLogMode,
206    /// Registered via [`AgentBuilder::on_log_event`] — fired for each
207    /// `AgentLogEvent` written to the session log while the terminal method
208    /// runs. Requires `session_log_mode != Disabled`.
209    log_event_callback: Option<LogEventCallback>,
210    /// Set via [`AgentBuilder::stream_events_to_stderr`] — overrides
211    /// `log_event_callback` to format and print each event to `stderr`.
212    stream_events_format: Option<ListenFormat>,
213    /// Whether the built-in stderr streamer should include reasoning events
214    /// (set via [`AgentBuilder::stream_show_thinking`]).
215    stream_show_thinking: bool,
216    /// Registered via [`AgentBuilder::on_spawn`] — invoked once with the
217    /// OS pid of the spawned agent subprocess right after spawn, before
218    /// the terminal wait. Useful for registering the child pid with an
219    /// external process store.
220    on_spawn_hook: Option<crate::agent::OnSpawnHook>,
221    /// Set via [`AgentBuilder::exit`] — when present, an interactive
222    /// session is augmented with instructions to terminate via
223    /// `zag ps kill self <result>`. `None` means `--exit` is unset;
224    /// `Some(ExitHint::Bare)` means the flag was passed without a hint;
225    /// `Some(ExitHint::Provided(s))` carries a hint message.
226    exit_hint: Option<crate::exit_mode::ExitHint>,
227    /// Set via [`AgentBuilder::register_process`] — when present, the
228    /// terminal method registers a `ProcessEntry` in zag's `ProcessStore`,
229    /// injects `ZAG_PROCESS_ID` / `ZAG_SESSION_ID` etc. into the agent
230    /// subprocess's env, retargets the entry's pid to the agent child via
231    /// an internal `on_spawn` hook, and finalises the entry's status when
232    /// the terminal method returns.
233    register_process_opts: Option<RegisterOptionsOwned>,
234}
235
236impl Default for AgentBuilder {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242impl AgentBuilder {
243    /// Create a new builder with default settings.
244    pub fn new() -> Self {
245        Self {
246            provider: None,
247            provider_explicit: false,
248            model: None,
249            system_prompt: None,
250            root: None,
251            auto_approve: false,
252            add_dirs: Vec::new(),
253            files: Vec::new(),
254            env_vars: Vec::new(),
255            worktree: None,
256            sandbox: None,
257            size: None,
258            json_mode: false,
259            json_schema: None,
260            session_id: None,
261            metadata: SessionMetadata::default(),
262            output_format: None,
263            input_format: None,
264            replay_user_messages: false,
265            include_partial_messages: false,
266            verbose: false,
267            quiet: false,
268            show_usage: false,
269            max_turns: None,
270            timeout: None,
271            mcp_config: None,
272            progress: Box::new(SilentProgress),
273            session_log_mode: SessionLogMode::Disabled,
274            log_event_callback: None,
275            stream_events_format: None,
276            stream_show_thinking: false,
277            on_spawn_hook: None,
278            register_process_opts: None,
279            exit_hint: None,
280        }
281    }
282
283    /// Set the provider (e.g., "claude", "codex", "gemini", "copilot", "ollama").
284    ///
285    /// Calling this method pins the provider — it will NOT be downgraded to
286    /// another provider in the tier list if its binary is missing or the
287    /// startup probe fails. Omit this call (or set `provider` via the config
288    /// file) to allow automatic downgrading.
289    pub fn provider(mut self, provider: &str) -> Self {
290        self.provider = Some(provider.to_string());
291        self.provider_explicit = true;
292        self
293    }
294
295    /// Set the model (e.g., "sonnet", "opus", "small", "large").
296    pub fn model(mut self, model: &str) -> Self {
297        self.model = Some(model.to_string());
298        self
299    }
300
301    /// Set a system prompt to configure agent behavior.
302    pub fn system_prompt(mut self, prompt: &str) -> Self {
303        self.system_prompt = Some(prompt.to_string());
304        self
305    }
306
307    /// Set the root directory for the agent to operate in.
308    pub fn root(mut self, root: &str) -> Self {
309        self.root = Some(root.to_string());
310        self
311    }
312
313    /// Enable auto-approve mode (skip permission prompts).
314    pub fn auto_approve(mut self, approve: bool) -> Self {
315        self.auto_approve = approve;
316        self
317    }
318
319    /// Add an additional directory for the agent to include.
320    pub fn add_dir(mut self, dir: &str) -> Self {
321        self.add_dirs.push(dir.to_string());
322        self
323    }
324
325    /// Attach a file to the prompt (text files ≤50 KB inlined, others referenced).
326    pub fn file(mut self, path: &str) -> Self {
327        self.files.push(path.to_string());
328        self
329    }
330
331    /// Add an environment variable for the agent subprocess.
332    pub fn env(mut self, key: &str, value: &str) -> Self {
333        self.env_vars.push((key.to_string(), value.to_string()));
334        self
335    }
336
337    /// Enable worktree mode with an optional name.
338    pub fn worktree(mut self, name: Option<&str>) -> Self {
339        self.worktree = Some(name.map(String::from));
340        self
341    }
342
343    /// Enable sandbox mode with an optional name.
344    pub fn sandbox(mut self, name: Option<&str>) -> Self {
345        self.sandbox = Some(name.map(String::from));
346        self
347    }
348
349    /// Set the Ollama parameter size (e.g., "2b", "9b", "35b").
350    pub fn size(mut self, size: &str) -> Self {
351        self.size = Some(size.to_string());
352        self
353    }
354
355    /// Request JSON output from the agent.
356    pub fn json(mut self) -> Self {
357        self.json_mode = true;
358        self
359    }
360
361    /// Set a JSON schema for structured output validation.
362    /// Implies `json()`.
363    pub fn json_schema(mut self, schema: serde_json::Value) -> Self {
364        self.json_schema = Some(schema);
365        self.json_mode = true;
366        self
367    }
368
369    /// Enable exit-on-kill mode for an interactive session.
370    ///
371    /// When set, the user prompt is augmented with instructions telling
372    /// the agent to terminate the session via
373    /// `zag ps kill self "<result>"` (or `zag ps kill self --file <path>`).
374    /// The `hint` is an optional human-readable description of the
375    /// expected result, used both to guide the agent and (when non-empty)
376    /// to require a non-empty result at kill time.
377    ///
378    /// Pass `Some(hint)` to set a hint, or `None` to enable exit mode
379    /// without one. Only meaningful for interactive `run` mode; using
380    /// this together with [`exec`](Self::exec) will fail at run time.
381    pub fn exit(mut self, hint: Option<&str>) -> Self {
382        self.exit_hint = Some(crate::exit_mode::ExitHint::from_optional(
383            hint.map(str::to_string),
384        ));
385        self
386    }
387
388    /// Set a specific session ID (UUID).
389    pub fn session_id(mut self, id: &str) -> Self {
390        self.session_id = Some(id.to_string());
391        self
392    }
393
394    /// Set a human-readable session name (mirrors the CLI's `--name`).
395    ///
396    /// Names are used by `zag input --name <n>`, `zag session list --name
397    /// <n>`, and for session discovery across the store. When the session
398    /// has a generated wrapper ID, the builder will persist this name to
399    /// the session store so CLI tools can find it later.
400    pub fn name(mut self, name: &str) -> Self {
401        self.metadata.name = Some(name.to_string());
402        self
403    }
404
405    /// Set a short description for the session (mirrors the CLI's
406    /// `--description`).
407    pub fn description(mut self, description: &str) -> Self {
408        self.metadata.description = Some(description.to_string());
409        self
410    }
411
412    /// Add a discovery tag for the session (mirrors the CLI's `--tag`,
413    /// repeatable).
414    pub fn tag(mut self, tag: &str) -> Self {
415        self.metadata.tags.push(tag.to_string());
416        self
417    }
418
419    /// Replace the full session metadata in one call.
420    pub fn metadata(mut self, metadata: SessionMetadata) -> Self {
421        self.metadata = metadata;
422        self
423    }
424
425    /// Set the output format (e.g., "text", "json", "json-pretty", "stream-json").
426    pub fn output_format(mut self, format: &str) -> Self {
427        self.output_format = Some(format.to_string());
428        self
429    }
430
431    /// Set the input format (Claude only, e.g., "text", "stream-json").
432    ///
433    /// No-op for Codex, Gemini, Copilot, and Ollama. See `docs/providers.md`
434    /// for the full per-provider support matrix.
435    pub fn input_format(mut self, format: &str) -> Self {
436        self.input_format = Some(format.to_string());
437        self
438    }
439
440    /// Re-emit user messages from stdin on stdout (Claude only).
441    ///
442    /// Only works with `--input-format stream-json` and `--output-format stream-json`.
443    /// [`exec_streaming`](Self::exec_streaming) auto-enables this flag, so most
444    /// callers never need to set it manually. No-op for non-Claude providers.
445    pub fn replay_user_messages(mut self, replay: bool) -> Self {
446        self.replay_user_messages = replay;
447        self
448    }
449
450    /// Include partial message chunks in streaming output (Claude only).
451    ///
452    /// Only works with `--output-format stream-json`. Defaults to `false`.
453    ///
454    /// When `false` (the default), streaming surfaces one `assistant_message`
455    /// event per complete assistant turn. When `true`, the agent instead emits
456    /// a stream of token-level partial `assistant_message` chunks as the model
457    /// generates them — use this for responsive, token-by-token UIs over
458    /// [`exec_streaming`](Self::exec_streaming). No-op for non-Claude providers.
459    pub fn include_partial_messages(mut self, include: bool) -> Self {
460        self.include_partial_messages = include;
461        self
462    }
463
464    /// Enable verbose output.
465    pub fn verbose(mut self, v: bool) -> Self {
466        self.verbose = v;
467        self
468    }
469
470    /// Enable quiet mode (suppress all non-essential output).
471    pub fn quiet(mut self, q: bool) -> Self {
472        self.quiet = q;
473        self
474    }
475
476    /// Show token usage statistics.
477    pub fn show_usage(mut self, show: bool) -> Self {
478        self.show_usage = show;
479        self
480    }
481
482    /// Set the maximum number of agentic turns.
483    pub fn max_turns(mut self, turns: u32) -> Self {
484        self.max_turns = Some(turns);
485        self
486    }
487
488    /// Set a timeout for exec. If the agent doesn't complete within this
489    /// duration, it will be killed and an error returned.
490    pub fn timeout(mut self, duration: std::time::Duration) -> Self {
491        self.timeout = Some(duration);
492        self
493    }
494
495    /// Set MCP server config for this invocation (Claude only).
496    ///
497    /// Accepts either a JSON string (`{"mcpServers": {...}}`) or a path to a JSON file.
498    /// No-op for Codex, Gemini, Copilot, and Ollama — those providers manage
499    /// MCP configuration through their own CLIs or do not support it. See
500    /// `docs/providers.md` for the full per-provider support matrix.
501    pub fn mcp_config(mut self, config: &str) -> Self {
502        self.mcp_config = Some(config.to_string());
503        self
504    }
505
506    /// Set a custom progress handler for status reporting.
507    pub fn on_progress(mut self, handler: Box<dyn ProgressHandler>) -> Self {
508        self.progress = handler;
509        self
510    }
511
512    /// Select how the builder manages the session log. See [`SessionLogMode`].
513    pub fn session_log(mut self, mode: SessionLogMode) -> Self {
514        self.session_log_mode = mode;
515        self
516    }
517
518    /// Shortcut for `.session_log(SessionLogMode::Auto)` when `true`, or
519    /// `.session_log(SessionLogMode::Disabled)` when `false`.
520    pub fn enable_session_log(mut self, enable: bool) -> Self {
521        self.session_log_mode = if enable {
522            SessionLogMode::Auto
523        } else {
524            SessionLogMode::Disabled
525        };
526        self
527    }
528
529    /// Register a callback fired for each `AgentLogEvent` written to the
530    /// session log during the terminal method. Implicitly switches
531    /// `session_log_mode` to [`SessionLogMode::Auto`] if it is currently
532    /// [`SessionLogMode::Disabled`].
533    pub fn on_log_event<F>(mut self, f: F) -> Self
534    where
535        F: Fn(&AgentLogEvent) + Send + Sync + 'static,
536    {
537        self.log_event_callback = Some(Arc::new(f));
538        if matches!(self.session_log_mode, SessionLogMode::Disabled) {
539            self.session_log_mode = SessionLogMode::Auto;
540        }
541        self
542    }
543
544    /// Convenience: tail the session log to stderr during the terminal
545    /// method, using the same formatters as the `zag listen` command.
546    ///
547    /// This is the drop-in replacement for the live stderr tail that a
548    /// previous shell-out-to-`zag` wrapper produced. Implicitly enables
549    /// session logging.
550    pub fn stream_events_to_stderr(mut self, format: ListenFormat) -> Self {
551        self.stream_events_format = Some(format);
552        if matches!(self.session_log_mode, SessionLogMode::Disabled) {
553            self.session_log_mode = SessionLogMode::Auto;
554        }
555        self
556    }
557
558    /// Include `Reasoning` events in the stderr stream when
559    /// [`stream_events_to_stderr`] is active. Off by default.
560    pub fn stream_show_thinking(mut self, show: bool) -> Self {
561        self.stream_show_thinking = show;
562        self
563    }
564
565    /// Register a callback invoked once with the OS pid of the spawned
566    /// agent subprocess, right after spawn and before the terminal
567    /// wait. Useful for registering the child pid with an external
568    /// process store so `zag ps kill self` (or equivalent) can SIGTERM
569    /// the agent child rather than the parent zag process.
570    ///
571    /// The callback fires once per spawn — on retries or resumes it
572    /// fires again for each new child. See [`crate::agent::OnSpawnHook`]
573    /// for the full semantics.
574    pub fn on_spawn<F>(mut self, f: F) -> Self
575    where
576        F: Fn(u32) + Send + Sync + 'static,
577    {
578        self.on_spawn_hook = Some(Arc::new(f));
579        self
580    }
581
582    /// Register the about-to-spawn agent in zag's `ProcessStore` and inject
583    /// `ZAG_PROCESS_ID` / `ZAG_SESSION_ID` / `ZAG_PROVIDER` / `ZAG_MODEL`
584    /// into its env so commands like `zag ps kill self` and `zig self
585    /// terminate` can resolve the running agent from inside its own
586    /// subshell.
587    ///
588    /// The terminal method takes care of:
589    ///
590    /// 1. Calling [`crate::process_registration::register`] before spawn.
591    /// 2. Appending the resulting env vars onto the builder.
592    /// 3. Composing the registration's `on_spawn` hook with any caller-set
593    ///    [`AgentBuilder::on_spawn`] so both fire.
594    /// 4. Calling [`ProcessRegistration::update_status`] with `"exited"` /
595    ///    `"killed"` once the agent finishes.
596    ///
597    /// Without this call, the builder leaves the registry untouched (the
598    /// pre-existing default behavior). Callers that already manage their
599    /// own `ProcessEntry` should not opt in.
600    pub fn register_process(mut self, opts: RegisterOptionsOwned) -> Self {
601        self.register_process_opts = Some(opts);
602        self
603    }
604}
605
606/// Apply a [`ProcessRegistration`] to the builder's `env_vars` and
607/// `on_spawn_hook` fields so the agent subprocess inherits the env vars and
608/// the registry entry's pid is retargeted on spawn. Composes with any
609/// caller-set `on_spawn` hook — both fire.
610fn apply_registration(builder: &mut AgentBuilder, reg: &ProcessRegistration) {
611    for (k, v) in reg.env_vars() {
612        builder.env_vars.push((k.clone(), v.clone()));
613    }
614    let reg_hook = reg.on_spawn_hook();
615    let prev_hook = builder.on_spawn_hook.take();
616    builder.on_spawn_hook = Some(Arc::new(move |pid: u32| {
617        reg_hook(pid);
618        if let Some(ref h) = prev_hook {
619            h(pid);
620        }
621    }));
622}
623
624/// Map a terminal-method `Result<T>` to the `(status, exit_code)` pair
625/// stored on the `ProcessEntry`. Mirrors what `zag-cli/src/commands/
626/// agent_action.rs` records: `"killed"` + the agent's reported exit code on
627/// failure, `"exited"` + 0 on success.
628fn status_for_result<T>(result: &Result<T>) -> (&'static str, Option<i32>) {
629    match result {
630        Ok(_) => ("exited", Some(0)),
631        Err(err) => {
632            let exit_code = err
633                .downcast_ref::<crate::process::ProcessError>()
634                .and_then(|pe| pe.exit_code)
635                .unwrap_or(1);
636            ("killed", Some(exit_code))
637        }
638    }
639}
640
641impl AgentBuilder {
642    /// Persist a `SessionEntry` to the session store so `zag session list`
643    /// and `zag input --name <n>` can discover this builder-spawned session.
644    ///
645    /// No-op when no metadata is set — callers who don't name their sessions
646    /// still get the old behavior of not leaving a trail in the session store.
647    ///
648    /// Returns the session ID that was persisted (either the caller-provided
649    /// one or a freshly generated UUID), so downstream logging can reference
650    /// the same ID.
651    fn persist_session_metadata_with_id(
652        &self,
653        provider: &str,
654        model: &str,
655        effective_root: Option<&str>,
656        explicit_session_id: Option<&str>,
657    ) -> Option<String> {
658        let has_metadata = self.metadata.name.is_some()
659            || self.metadata.description.is_some()
660            || !self.metadata.tags.is_empty()
661            // `--exit` sessions must also be persisted so `zag ps kill`
662            // can look up the exit-mode constraints when validating the
663            // submitted result.
664            || self.exit_hint.is_some();
665        if !has_metadata {
666            return None;
667        }
668
669        let session_id = explicit_session_id
670            .map(String::from)
671            .or_else(|| self.session_id.clone())
672            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
673        let workspace_path = effective_root
674            .map(String::from)
675            .or_else(|| self.root.clone())
676            .unwrap_or_else(|| {
677                std::env::current_dir()
678                    .map(|p| p.to_string_lossy().to_string())
679                    .unwrap_or_default()
680            });
681
682        let entry = SessionEntry {
683            session_id: session_id.clone(),
684            provider: provider.to_string(),
685            model: model.to_string(),
686            worktree_path: workspace_path,
687            worktree_name: String::new(),
688            created_at: chrono::Utc::now().to_rfc3339(),
689            provider_session_id: None,
690            sandbox_name: None,
691            is_worktree: self.worktree.is_some(),
692            discovered: false,
693            discovery_source: None,
694            log_path: None,
695            log_completeness: "partial".to_string(),
696            name: self.metadata.name.clone(),
697            description: self.metadata.description.clone(),
698            tags: self.metadata.tags.clone(),
699            dependencies: Vec::new(),
700            retried_from: None,
701            interactive: false,
702            exit: self
703                .exit_hint
704                .as_ref()
705                .map(|h| crate::exit_mode::ExitConstraints {
706                    hint: Some(h.clone()),
707                    json_mode: self.json_mode,
708                    schema: self.json_schema.clone(),
709                }),
710        };
711
712        let mut store = SessionStore::load(self.root.as_deref()).unwrap_or_default();
713        store.add(entry);
714        if let Err(e) = store.save(self.root.as_deref()) {
715            warn!("Failed to persist session metadata: {e}");
716        }
717
718        Some(session_id)
719    }
720
721    /// Resolve file attachments and prepend them to a prompt.
722    fn prepend_files(&self, prompt: &str) -> Result<String> {
723        if self.files.is_empty() {
724            return Ok(prompt.to_string());
725        }
726        let attachments: Vec<Attachment> = self
727            .files
728            .iter()
729            .map(|f| Attachment::from_path(std::path::Path::new(f)))
730            .collect::<Result<Vec<_>>>()?;
731        let prefix = attachment::format_attachments_prefix(&attachments);
732        Ok(format!("{prefix}{prompt}"))
733    }
734
735    /// Resolve the effective provider name.
736    fn resolve_provider(&self) -> Result<String> {
737        if let Some(ref p) = self.provider {
738            let p = p.to_lowercase();
739            if !Config::VALID_PROVIDERS.contains(&p.as_str()) {
740                bail!(
741                    "Invalid provider '{}'. Available: {}",
742                    p,
743                    Config::VALID_PROVIDERS.join(", ")
744                );
745            }
746            return Ok(p);
747        }
748        let config = Config::load(self.root.as_deref()).unwrap_or_default();
749        if let Some(p) = config.provider() {
750            return Ok(p.to_string());
751        }
752        Ok("claude".to_string())
753    }
754
755    /// Create and configure the agent.
756    ///
757    /// Returns the constructed agent along with the provider name that
758    /// actually succeeded. When `provider_explicit` is false, the factory
759    /// may downgrade to another provider in the tier list, so the returned
760    /// provider can differ from the one passed in.
761    async fn create_agent(&self, provider: &str) -> Result<(Box<dyn Agent + Send + Sync>, String)> {
762        // Apply system_prompt config fallback
763        let base_system_prompt = self.system_prompt.clone().or_else(|| {
764            Config::load(self.root.as_deref())
765                .unwrap_or_default()
766                .system_prompt()
767                .map(String::from)
768        });
769
770        // Augment system prompt with JSON instructions for non-Claude agents
771        let system_prompt = if self.json_mode && provider != "claude" {
772            let mut prompt = base_system_prompt.unwrap_or_default();
773            if let Some(ref schema) = self.json_schema {
774                let schema_str = serde_json::to_string_pretty(schema).unwrap_or_default();
775                prompt.push_str(&format!(
776                    "\n\nYou MUST respond with valid JSON only. No markdown fences, no explanations. \
777                     Your response must conform to this JSON schema:\n{schema_str}"
778                ));
779            } else {
780                prompt.push_str(
781                    "\n\nYou MUST respond with valid JSON only. No markdown fences, no explanations.",
782                );
783            }
784            Some(prompt)
785        } else {
786            base_system_prompt
787        };
788
789        self.progress
790            .on_spinner_start(&format!("Initializing {provider} agent"));
791
792        let progress = &*self.progress;
793        let mut on_downgrade = |from: &str, to: &str, reason: &str| {
794            progress.on_warning(&format!("Downgrading provider: {from} → {to} ({reason})"));
795        };
796        let (mut agent, effective_provider) = AgentFactory::create_with_fallback(
797            provider,
798            self.provider_explicit,
799            system_prompt,
800            self.model.clone(),
801            self.root.clone(),
802            self.auto_approve,
803            self.add_dirs.clone(),
804            &mut on_downgrade,
805        )
806        .await?;
807        let provider = effective_provider.as_str();
808
809        // Apply max_turns: explicit > config > none
810        let effective_max_turns = self.max_turns.or_else(|| {
811            Config::load(self.root.as_deref())
812                .unwrap_or_default()
813                .max_turns()
814        });
815        if let Some(turns) = effective_max_turns {
816            agent.set_max_turns(turns);
817        }
818
819        // Set output format
820        let mut output_format = self.output_format.clone();
821        if self.json_mode && output_format.is_none() {
822            output_format = Some("json".to_string());
823            if provider != "claude" {
824                agent.set_capture_output(true);
825            }
826        }
827        agent.set_output_format(output_format);
828
829        // Configure Claude-specific options
830        if provider == "claude"
831            && let Some(claude_agent) = agent.as_any_mut().downcast_mut::<Claude>()
832        {
833            claude_agent.set_verbose(self.verbose);
834            if let Some(ref session_id) = self.session_id {
835                claude_agent.set_session_id(session_id.clone());
836            }
837            if let Some(ref input_fmt) = self.input_format {
838                claude_agent.set_input_format(Some(input_fmt.clone()));
839            }
840            if self.replay_user_messages {
841                claude_agent.set_replay_user_messages(true);
842            }
843            if self.include_partial_messages {
844                claude_agent.set_include_partial_messages(true);
845            }
846            if self.json_mode
847                && let Some(ref schema) = self.json_schema
848            {
849                let schema_str = serde_json::to_string(schema).unwrap_or_default();
850                claude_agent.set_json_schema(Some(schema_str));
851            }
852            if self.mcp_config.is_some() {
853                claude_agent.set_mcp_config(self.mcp_config.clone());
854            }
855        }
856
857        // Configure Ollama-specific options
858        if provider == "ollama"
859            && let Some(ollama_agent) = agent.as_any_mut().downcast_mut::<Ollama>()
860        {
861            let config = Config::load(self.root.as_deref()).unwrap_or_default();
862            if let Some(ref size) = self.size {
863                let resolved = config.ollama_size_for(size);
864                ollama_agent.set_size(resolved.to_string());
865            }
866        }
867
868        // Configure sandbox
869        if let Some(ref sandbox_opt) = self.sandbox {
870            let sandbox_name = sandbox_opt
871                .as_deref()
872                .map(String::from)
873                .unwrap_or_else(crate::sandbox::generate_name);
874            let template = crate::sandbox::template_for_provider(provider);
875            let workspace = self.root.clone().unwrap_or_else(|| ".".to_string());
876            agent.set_sandbox(SandboxConfig {
877                name: sandbox_name,
878                template: template.to_string(),
879                workspace,
880            });
881        }
882
883        if !self.env_vars.is_empty() {
884            agent.set_env_vars(self.env_vars.clone());
885        }
886
887        if let Some(ref hook) = self.on_spawn_hook {
888            agent.set_on_spawn_hook(hook.clone());
889        }
890
891        self.progress.on_spinner_finish();
892        self.progress.on_success(&format!(
893            "{} initialized with model {}",
894            provider,
895            agent.get_model()
896        ));
897
898        Ok((agent, effective_provider))
899    }
900
901    /// Start (or adopt) a [`SessionLogCoordinator`] for the session about
902    /// to run, honouring the builder's [`SessionLogMode`] and wiring up any
903    /// registered `on_log_event` / `stream_events_to_stderr` callback.
904    ///
905    /// Returns a guard that owns the coordinator (where applicable) and the
906    /// resolved `wrapper_session_id` + log path, or `None` when logging is
907    /// disabled.
908    fn start_session_log(
909        &mut self,
910        command: &str,
911        resumed: bool,
912        provider: &str,
913        model: &str,
914        provider_session_id: Option<&str>,
915    ) -> Option<SessionLogGuard> {
916        let mode = std::mem::replace(&mut self.session_log_mode, SessionLogMode::Disabled);
917        match mode {
918            SessionLogMode::Disabled => None,
919            SessionLogMode::External(c) => {
920                let wrapper_session_id = c
921                    .writer()
922                    .log_path()
923                    .ok()
924                    .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
925                    .unwrap_or_default();
926                let log_path = c.writer().log_path().ok();
927                self.apply_event_callback(c.writer());
928                Some(SessionLogGuard {
929                    coordinator: None, // externally owned
930                    wrapper_session_id,
931                    log_path,
932                    external_writer: Some(c.writer().clone()),
933                    _owned_external: Some(c),
934                })
935            }
936            SessionLogMode::Auto => {
937                let wrapper_session_id = self
938                    .session_id
939                    .clone()
940                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
941                let metadata = SessionLogMetadata {
942                    provider: provider.to_string(),
943                    wrapper_session_id: wrapper_session_id.clone(),
944                    provider_session_id: provider_session_id.map(str::to_string),
945                    workspace_path: self.root.clone().or_else(|| {
946                        std::env::current_dir()
947                            .ok()
948                            .map(|p| p.to_string_lossy().to_string())
949                    }),
950                    command: command.to_string(),
951                    model: Some(model.to_string()),
952                    resumed,
953                    backfilled: false,
954                };
955                let live_ctx = LiveLogContext {
956                    root: self.root.clone(),
957                    provider_session_id: metadata.provider_session_id.clone(),
958                    workspace_path: metadata.workspace_path.clone(),
959                    started_at: chrono::Utc::now(),
960                    is_worktree: self.worktree.is_some(),
961                };
962                let adapter = live_adapter_for_provider(provider, live_ctx, true);
963                let callback = self.build_event_callback();
964                match SessionLogCoordinator::start_with_callback(
965                    &logs_dir(self.root.as_deref()),
966                    metadata,
967                    adapter,
968                    callback,
969                ) {
970                    Ok(c) => {
971                        let _ = c.writer().set_global_index_dir(Config::global_base_dir());
972                        let log_path = c.writer().log_path().ok();
973                        Some(SessionLogGuard {
974                            coordinator: Some(c),
975                            wrapper_session_id,
976                            log_path,
977                            external_writer: None,
978                            _owned_external: None,
979                        })
980                    }
981                    Err(e) => {
982                        warn!("Failed to start session log coordinator: {e}");
983                        None
984                    }
985                }
986            }
987        }
988    }
989
990    /// Build a combined event callback from any registered `on_log_event`
991    /// and `stream_events_to_stderr` setters. Returns `None` when neither
992    /// is set so the writer doesn't pay any per-event cost.
993    fn build_event_callback(&self) -> Option<LogEventCallback> {
994        let user_cb = self.log_event_callback.clone();
995        let stream_fmt = self.stream_events_format;
996        let show_thinking = self.stream_show_thinking;
997
998        if user_cb.is_none() && stream_fmt.is_none() {
999            return None;
1000        }
1001
1002        Some(Arc::new(move |event: &AgentLogEvent| {
1003            if let Some(ref user) = user_cb {
1004                user(event);
1005            }
1006            if let Some(fmt) = stream_fmt
1007                && let Some(text) = listen::format_event(event, fmt, show_thinking)
1008            {
1009                eprintln!("{text}");
1010            }
1011        }))
1012    }
1013
1014    /// Register the builder's callback on an externally-owned writer (used
1015    /// by [`SessionLogMode::External`] — the coordinator is already running
1016    /// so we can't register the callback before `SessionStarted`, but any
1017    /// post-adoption event will still fire).
1018    fn apply_event_callback(&self, writer: &crate::session_log::SessionLogWriter) {
1019        if let Some(cb) = self.build_event_callback() {
1020            if let Err(e) = writer.set_event_callback(cb) {
1021                warn!("Failed to register session log event callback: {e}");
1022            }
1023        }
1024    }
1025
1026    /// Run the agent non-interactively and return structured output.
1027    ///
1028    /// This is the primary entry point for programmatic use.
1029    pub async fn exec(mut self, prompt: &str) -> Result<AgentOutput> {
1030        let registration = self
1031            .register_process_opts
1032            .as_ref()
1033            .map(|opts| process_registration::register(opts.as_borrowed()));
1034        if let Some(ref reg) = registration {
1035            apply_registration(&mut self, reg);
1036        }
1037        let result = self.exec_inner(prompt).await;
1038        if let Some(reg) = registration {
1039            let (status, code) = status_for_result(&result);
1040            reg.update_status(status, code);
1041        }
1042        result
1043    }
1044
1045    async fn exec_inner(self, prompt: &str) -> Result<AgentOutput> {
1046        if self.exit_hint.is_some() {
1047            bail!(
1048                "`exit()` is only valid with `run()`. `exec()` already produces a structured \
1049                 output natively, so combining the two would be ambiguous."
1050            );
1051        }
1052        let provider = self.resolve_provider()?;
1053        debug!("exec: provider={provider}");
1054
1055        // Set up worktree if requested
1056        let effective_root = if let Some(ref wt_opt) = self.worktree {
1057            let wt_name = wt_opt
1058                .as_deref()
1059                .map(String::from)
1060                .unwrap_or_else(worktree::generate_name);
1061            let repo_root = worktree::git_repo_root(self.root.as_deref())?;
1062            let wt_path = worktree::create_worktree(&repo_root, &wt_name)?;
1063            self.progress
1064                .on_success(&format!("Worktree created at {}", wt_path.display()));
1065            Some(wt_path.to_string_lossy().to_string())
1066        } else {
1067            self.root.clone()
1068        };
1069
1070        let mut builder = self;
1071        if effective_root.is_some() {
1072            builder.root = effective_root;
1073        }
1074
1075        let (agent, provider) = builder.create_agent(&provider).await?;
1076
1077        // Start (or adopt) the session log coordinator. Held for the whole
1078        // terminal method; dropped after cleanup so the log file is
1079        // finalised exactly once.
1080        let log_guard =
1081            builder.start_session_log("exec", false, &provider, agent.get_model(), None);
1082
1083        // Persist the session entry so discovery (session list --name, input
1084        // --name) works for builder-spawned sessions. No-op when no metadata
1085        // is set. When session logging is active, share its wrapper_session_id
1086        // so the store entry and the JSONL log agree.
1087        let _ = builder.persist_session_metadata_with_id(
1088            &provider,
1089            agent.get_model(),
1090            builder.root.as_deref(),
1091            log_guard.as_ref().map(|g| g.wrapper_session_id.as_str()),
1092        );
1093
1094        // Prepend file attachments
1095        let prompt_with_files = builder.prepend_files(prompt)?;
1096
1097        // Handle JSON mode with prompt wrapping for non-Claude agents
1098        let effective_prompt = if builder.json_mode && provider != "claude" {
1099            format!(
1100                "IMPORTANT: You MUST respond with valid JSON only. No markdown, no explanation.\n\n{prompt_with_files}"
1101            )
1102        } else {
1103            prompt_with_files
1104        };
1105
1106        let result = if let Some(timeout_dur) = builder.timeout {
1107            match tokio::time::timeout(timeout_dur, agent.run(Some(&effective_prompt))).await {
1108                Ok(r) => r?,
1109                Err(_) => {
1110                    agent.cleanup().await.ok();
1111                    bail!("Agent timed out after {}", format_duration(timeout_dur));
1112                }
1113            }
1114        } else {
1115            agent.run(Some(&effective_prompt)).await?
1116        };
1117
1118        // Clean up
1119        agent.cleanup().await?;
1120
1121        let log_path_string = log_guard.as_ref().and_then(|g| g.log_path_string());
1122
1123        if let Some(mut output) = result {
1124            // Validate JSON output if schema is provided
1125            if let Some(ref schema) = builder.json_schema {
1126                if !builder.json_mode {
1127                    warn!(
1128                        "json_schema is set but json_mode is false — \
1129                         schema will not be sent to the agent, only used for output validation"
1130                    );
1131                }
1132                if let Some(ref result_text) = output.result {
1133                    debug!(
1134                        "exec: validating result ({} bytes): {:.300}",
1135                        result_text.len(),
1136                        result_text
1137                    );
1138                    if let Err(errors) = json_validation::validate_json_schema(result_text, schema)
1139                    {
1140                        let preview = if result_text.len() > 500 {
1141                            &result_text[..500]
1142                        } else {
1143                            result_text.as_str()
1144                        };
1145                        bail!(
1146                            "JSON schema validation failed: {}\nRaw agent output ({} bytes):\n{}",
1147                            errors.join("; "),
1148                            result_text.len(),
1149                            preview
1150                        );
1151                    }
1152                }
1153            }
1154            output.log_path = log_path_string;
1155            let success = !output.is_error;
1156            let err_msg = output.error_message.clone();
1157            if let Some(g) = log_guard {
1158                g.finish(success, err_msg).await;
1159            }
1160            Ok(output)
1161        } else {
1162            // Agent returned no structured output — create a minimal one
1163            let mut output = AgentOutput::from_text(&provider, "");
1164            output.log_path = log_path_string;
1165            if let Some(g) = log_guard {
1166                g.finish(true, None).await;
1167            }
1168            Ok(output)
1169        }
1170    }
1171
1172    /// Run the agent with streaming input and output (Claude only).
1173    ///
1174    /// Returns a [`StreamingSession`] that allows sending NDJSON messages to
1175    /// the agent's stdin and reading events from stdout. Automatically
1176    /// configures `--input-format stream-json`, `--output-format stream-json`,
1177    /// and `--replay-user-messages`.
1178    ///
1179    /// # Default emission granularity
1180    ///
1181    /// By default `assistant_message` events are emitted **once per complete
1182    /// assistant turn** — you get one event when the model finishes speaking,
1183    /// not a stream of token chunks. For responsive, token-level UIs call
1184    /// [`include_partial_messages(true)`](Self::include_partial_messages)
1185    /// on the builder before `exec_streaming`; the session will then emit
1186    /// partial `assistant_message` chunks as the model generates them.
1187    ///
1188    /// The default is kept `false` so existing callers that render whole-turn
1189    /// bubbles are not broken. See `docs/providers.md` for the full
1190    /// per-provider flag support matrix.
1191    ///
1192    /// # Event lifecycle
1193    ///
1194    /// The session emits a unified
1195    /// [`Event::Result`](crate::output::Event::Result) at the **end of every
1196    /// agent turn** — not only at final session end. Use that event as the
1197    /// authoritative turn-boundary signal. After a `Result`, the session
1198    /// remains open and accepts another
1199    /// [`send_user_message`](StreamingSession::send_user_message) for the next
1200    /// turn. Call
1201    /// [`close_input`](StreamingSession::close_input) followed by
1202    /// [`wait`](StreamingSession::wait) to terminate the session cleanly.
1203    ///
1204    /// Do not depend on replayed `user_message` events to detect turn
1205    /// boundaries; those only appear while `--replay-user-messages` is set.
1206    ///
1207    /// # Examples
1208    ///
1209    /// ```no_run
1210    /// use zag_agent::builder::AgentBuilder;
1211    /// use zag_agent::output::Event;
1212    ///
1213    /// # async fn example() -> anyhow::Result<()> {
1214    /// let mut session = AgentBuilder::new()
1215    ///     .provider("claude")
1216    ///     .exec_streaming("initial prompt")
1217    ///     .await?;
1218    ///
1219    /// // Drain the first turn until Result.
1220    /// while let Some(event) = session.next_event().await? {
1221    ///     println!("{:?}", event);
1222    ///     if matches!(event, Event::Result { .. }) {
1223    ///         break;
1224    ///     }
1225    /// }
1226    ///
1227    /// // Follow-up turn.
1228    /// session.send_user_message("do something else").await?;
1229    /// while let Some(event) = session.next_event().await? {
1230    ///     if matches!(event, Event::Result { .. }) {
1231    ///         break;
1232    ///     }
1233    /// }
1234    ///
1235    /// session.close_input();
1236    /// session.wait().await?;
1237    /// # Ok(())
1238    /// # }
1239    /// ```
1240    pub async fn exec_streaming(self, prompt: &str) -> Result<StreamingSession> {
1241        let provider = self.resolve_provider()?;
1242        debug!("exec_streaming: provider={provider}");
1243
1244        if provider != "claude" {
1245            bail!("Streaming input is only supported by the Claude provider");
1246        }
1247
1248        // Prepend file attachments
1249        let prompt_with_files = self.prepend_files(prompt)?;
1250
1251        // Streaming only works on Claude — do not allow the fallback loop
1252        // to downgrade to a provider that can't stream.
1253        let mut builder = self;
1254        builder.provider_explicit = true;
1255        let (agent, _provider) = builder.create_agent(&provider).await?;
1256
1257        // Downcast to Claude to call execute_streaming
1258        let claude_agent = agent
1259            .as_any_ref()
1260            .downcast_ref::<Claude>()
1261            .ok_or_else(|| anyhow::anyhow!("Failed to downcast agent to Claude"))?;
1262
1263        claude_agent.execute_streaming(Some(&prompt_with_files))
1264    }
1265
1266    /// Start an interactive agent session.
1267    ///
1268    /// This takes over stdin/stdout for the duration of the session.
1269    pub async fn run(mut self, prompt: Option<&str>) -> Result<()> {
1270        let registration = self
1271            .register_process_opts
1272            .as_ref()
1273            .map(|opts| process_registration::register(opts.as_borrowed()));
1274        if let Some(ref reg) = registration {
1275            apply_registration(&mut self, reg);
1276        }
1277        let result = self.run_inner(prompt).await;
1278        if let Some(reg) = registration {
1279            let (status, code) = status_for_result(&result);
1280            reg.update_status(status, code);
1281        }
1282        result
1283    }
1284
1285    async fn run_inner(self, prompt: Option<&str>) -> Result<()> {
1286        let provider = self.resolve_provider()?;
1287        debug!("run: provider={provider}");
1288
1289        // Prepend file attachments
1290        let prompt_with_files = match prompt {
1291            Some(p) => Some(self.prepend_files(p)?),
1292            None if !self.files.is_empty() => {
1293                let attachments: Vec<Attachment> = self
1294                    .files
1295                    .iter()
1296                    .map(|f| Attachment::from_path(std::path::Path::new(f)))
1297                    .collect::<Result<Vec<_>>>()?;
1298                Some(attachment::format_attachments_prefix(&attachments))
1299            }
1300            None => None,
1301        };
1302
1303        // Append the `--exit` suffix when set so library callers using
1304        // `.exit(...).run(...)` get the same prompt augmentation as the
1305        // CLI path in `zag-cli/src/commands/agent_action.rs`.
1306        let prompt_with_exit = match (self.exit_hint.as_ref(), prompt_with_files) {
1307            (Some(hint), Some(p)) => {
1308                let suffix = crate::exit_mode::build_exit_suffix(
1309                    hint.as_str(),
1310                    self.json_mode,
1311                    self.json_schema.as_ref(),
1312                );
1313                Some(format!("{p}\n\n{suffix}"))
1314            }
1315            (Some(hint), None) => Some(crate::exit_mode::build_exit_suffix(
1316                hint.as_str(),
1317                self.json_mode,
1318                self.json_schema.as_ref(),
1319            )),
1320            (None, p) => p,
1321        };
1322
1323        let mut builder = self;
1324        let (agent, effective_provider) = builder.create_agent(&provider).await?;
1325        let log_guard =
1326            builder.start_session_log("run", false, &effective_provider, agent.get_model(), None);
1327        let _ = builder.persist_session_metadata_with_id(
1328            &effective_provider,
1329            agent.get_model(),
1330            builder.root.as_deref(),
1331            log_guard.as_ref().map(|g| g.wrapper_session_id.as_str()),
1332        );
1333        agent.run_interactive(prompt_with_exit.as_deref()).await?;
1334        agent.cleanup().await?;
1335        if let Some(g) = log_guard {
1336            g.finish(true, None).await;
1337        }
1338        Ok(())
1339    }
1340
1341    /// Resume a previous session by ID.
1342    pub async fn resume(mut self, session_id: &str) -> Result<()> {
1343        let registration = self
1344            .register_process_opts
1345            .as_ref()
1346            .map(|opts| process_registration::register(opts.as_borrowed()));
1347        if let Some(ref reg) = registration {
1348            apply_registration(&mut self, reg);
1349        }
1350        let result = self.resume_inner(session_id).await;
1351        if let Some(reg) = registration {
1352            let (status, code) = status_for_result(&result);
1353            reg.update_status(status, code);
1354        }
1355        result
1356    }
1357
1358    async fn resume_inner(self, session_id: &str) -> Result<()> {
1359        let provider = self.resolve_provider()?;
1360        debug!("resume: provider={provider}, session={session_id}");
1361
1362        // Resuming must stick with the recorded provider — no downgrade.
1363        let mut builder = self;
1364        builder.provider_explicit = true;
1365        let (agent, effective_provider) = builder.create_agent(&provider).await?;
1366        let log_guard = builder.start_session_log(
1367            "resume",
1368            true,
1369            &effective_provider,
1370            agent.get_model(),
1371            Some(session_id),
1372        );
1373        agent.run_resume(Some(session_id), false).await?;
1374        agent.cleanup().await?;
1375        if let Some(g) = log_guard {
1376            g.finish(true, None).await;
1377        }
1378        Ok(())
1379    }
1380
1381    /// Resume a previous session and inject a new user message as the next
1382    /// turn. Captures the agent's response (analogous to [`exec`](Self::exec)).
1383    ///
1384    /// Unlike [`resume`](Self::resume), this method is non-interactive — it
1385    /// does not attach to stdio and instead returns the structured
1386    /// [`AgentOutput`] produced by the agent for the injected prompt.
1387    ///
1388    /// Provider support mirrors the underlying
1389    /// [`Agent::run_resume_with_prompt`](crate::agent::Agent::run_resume_with_prompt)
1390    /// trait method: Claude, Codex, and the mock provider implement it; the
1391    /// trait's default impl errors out for providers that don't, so callers
1392    /// see a clear "unsupported" message rather than silent misbehavior.
1393    pub async fn resume_with_prompt(
1394        mut self,
1395        session_id: &str,
1396        prompt: &str,
1397    ) -> Result<Option<AgentOutput>> {
1398        let registration = self
1399            .register_process_opts
1400            .as_ref()
1401            .map(|opts| process_registration::register(opts.as_borrowed()));
1402        if let Some(ref reg) = registration {
1403            apply_registration(&mut self, reg);
1404        }
1405        let result = self.resume_with_prompt_inner(session_id, prompt).await;
1406        if let Some(reg) = registration {
1407            let (status, code) = status_for_result(&result);
1408            reg.update_status(status, code);
1409        }
1410        result
1411    }
1412
1413    async fn resume_with_prompt_inner(
1414        self,
1415        session_id: &str,
1416        prompt: &str,
1417    ) -> Result<Option<AgentOutput>> {
1418        let provider = self.resolve_provider()?;
1419        debug!("resume_with_prompt: provider={provider}, session={session_id}");
1420
1421        // Resuming must stick with the recorded provider — no downgrade.
1422        let mut builder = self;
1423        builder.provider_explicit = true;
1424        let (agent, effective_provider) = builder.create_agent(&provider).await?;
1425        // Threading `session_id` here lets the live-log adapter locate the
1426        // provider transcript file by ID, instead of falling back to
1427        // "most-recently-modified" guesswork — which is what gives the
1428        // builder's `on_log_event` callback per-turn delivery during a
1429        // resumed turn.
1430        let log_guard = builder.start_session_log(
1431            "resume",
1432            true,
1433            &effective_provider,
1434            agent.get_model(),
1435            Some(session_id),
1436        );
1437        let output = agent.run_resume_with_prompt(session_id, prompt).await?;
1438        agent.cleanup().await?;
1439        if let Some(g) = log_guard {
1440            g.finish(true, None).await;
1441        }
1442        Ok(output)
1443    }
1444
1445    /// Resume the most recent session.
1446    pub async fn continue_last(mut self) -> Result<()> {
1447        let registration = self
1448            .register_process_opts
1449            .as_ref()
1450            .map(|opts| process_registration::register(opts.as_borrowed()));
1451        if let Some(ref reg) = registration {
1452            apply_registration(&mut self, reg);
1453        }
1454        let result = self.continue_last_inner().await;
1455        if let Some(reg) = registration {
1456            let (status, code) = status_for_result(&result);
1457            reg.update_status(status, code);
1458        }
1459        result
1460    }
1461
1462    async fn continue_last_inner(self) -> Result<()> {
1463        let provider = self.resolve_provider()?;
1464        debug!("continue_last: provider={provider}");
1465
1466        // Resuming must stick with the recorded provider — no downgrade.
1467        let mut builder = self;
1468        builder.provider_explicit = true;
1469        let (agent, effective_provider) = builder.create_agent(&provider).await?;
1470        let log_guard =
1471            builder.start_session_log("resume", true, &effective_provider, agent.get_model(), None);
1472        agent.run_resume(None, true).await?;
1473        agent.cleanup().await?;
1474        if let Some(g) = log_guard {
1475            g.finish(true, None).await;
1476        }
1477        Ok(())
1478    }
1479}
1480
1481#[cfg(test)]
1482#[path = "builder_tests.rs"]
1483mod tests;