Skip to main content

zag_agent/providers/
gemini.rs

1// provider-updated: 2026-04-05
2use crate::agent::{Agent, ModelSize};
3use crate::output::AgentOutput;
4use crate::sandbox::SandboxConfig;
5use crate::session_log::{
6    BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
7    LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter,
8};
9use anyhow::{Context, Result};
10use async_trait::async_trait;
11use log::info;
12use std::collections::HashSet;
13use std::path::Path;
14use std::process::Stdio;
15use tokio::fs;
16use tokio::process::Command;
17
18/// Return the Gemini tmp directory: `~/.gemini/tmp/`.
19pub fn tmp_dir() -> Option<std::path::PathBuf> {
20    dirs::home_dir().map(|h| h.join(".gemini/tmp"))
21}
22
23pub const DEFAULT_MODEL: &str = "auto";
24
25pub const AVAILABLE_MODELS: &[&str] = &[
26    "auto",
27    "gemini-3.1-pro-preview",
28    "gemini-3.1-flash-lite-preview",
29    "gemini-3-pro-preview",
30    "gemini-3-flash-preview",
31    "gemini-2.5-pro",
32    "gemini-2.5-flash",
33    "gemini-2.5-flash-lite",
34];
35
36pub struct Gemini {
37    system_prompt: String,
38    model: String,
39    root: Option<String>,
40    skip_permissions: bool,
41    output_format: Option<String>,
42    add_dirs: Vec<String>,
43    capture_output: bool,
44    sandbox: Option<SandboxConfig>,
45    max_turns: Option<u32>,
46    env_vars: Vec<(String, String)>,
47}
48
49pub struct GeminiLiveLogAdapter {
50    ctx: LiveLogContext,
51    session_path: Option<std::path::PathBuf>,
52    emitted_message_ids: std::collections::HashSet<String>,
53}
54
55pub struct GeminiHistoricalLogAdapter;
56
57impl Gemini {
58    pub fn new() -> Self {
59        Self {
60            system_prompt: String::new(),
61            model: DEFAULT_MODEL.to_string(),
62            root: None,
63            skip_permissions: false,
64            output_format: None,
65            add_dirs: Vec::new(),
66            capture_output: false,
67            sandbox: None,
68            max_turns: None,
69            env_vars: Vec::new(),
70        }
71    }
72
73    fn get_base_path(&self) -> &Path {
74        self.root.as_ref().map(Path::new).unwrap_or(Path::new("."))
75    }
76
77    async fn write_system_file(&self) -> Result<()> {
78        let base = self.get_base_path();
79        log::debug!("Writing Gemini system file to {}", base.display());
80        let gemini_dir = base.join(".gemini");
81        fs::create_dir_all(&gemini_dir).await?;
82        fs::write(gemini_dir.join("system.md"), &self.system_prompt).await?;
83        Ok(())
84    }
85
86    /// Build the argument list for a run/exec invocation.
87    fn build_run_args(&self, interactive: bool, prompt: Option<&str>) -> Vec<String> {
88        let mut args = Vec::new();
89
90        if self.skip_permissions {
91            args.extend(["--approval-mode", "yolo"].map(String::from));
92        }
93
94        if !self.model.is_empty() && self.model != "auto" {
95            args.extend(["--model".to_string(), self.model.clone()]);
96        }
97
98        for dir in &self.add_dirs {
99            args.extend(["--include-directories".to_string(), dir.clone()]);
100        }
101
102        if !interactive && let Some(ref format) = self.output_format {
103            args.extend(["--output-format".to_string(), format.clone()]);
104        }
105
106        // Note: Gemini CLI does not support --max-turns as a CLI flag.
107        // Max turns is configured via settings.json (maxSessionTurns).
108        // The value is stored but not passed as an argument.
109
110        if let Some(p) = prompt {
111            args.push(p.to_string());
112        }
113
114        args
115    }
116
117    /// Create a `Command` either directly or wrapped in sandbox.
118    fn make_command(&self, agent_args: Vec<String>) -> Command {
119        if let Some(ref sb) = self.sandbox {
120            let std_cmd = crate::sandbox::build_sandbox_command(sb, agent_args);
121            Command::from(std_cmd)
122        } else {
123            let mut cmd = Command::new("gemini");
124            if let Some(ref root) = self.root {
125                cmd.current_dir(root);
126            }
127            cmd.args(&agent_args);
128            for (key, value) in &self.env_vars {
129                cmd.env(key, value);
130            }
131            cmd
132        }
133    }
134
135    async fn execute(
136        &self,
137        interactive: bool,
138        prompt: Option<&str>,
139    ) -> Result<Option<AgentOutput>> {
140        if !self.system_prompt.is_empty() {
141            log::debug!(
142                "Gemini system prompt (written to system.md): {}",
143                self.system_prompt
144            );
145            self.write_system_file().await?;
146        }
147
148        let agent_args = self.build_run_args(interactive, prompt);
149        log::debug!("Gemini command: gemini {}", agent_args.join(" "));
150        if let Some(p) = prompt {
151            log::debug!("Gemini user prompt: {}", p);
152        }
153        let mut cmd = self.make_command(agent_args);
154
155        if !self.system_prompt.is_empty() {
156            cmd.env("GEMINI_SYSTEM_MD", "true");
157        }
158
159        if interactive {
160            cmd.stdin(Stdio::inherit())
161                .stdout(Stdio::inherit())
162                .stderr(Stdio::inherit());
163            let status = cmd
164                .status()
165                .await
166                .context("Failed to execute 'gemini' CLI. Is it installed and in PATH?")?;
167            if !status.success() {
168                return Err(crate::process::ProcessError {
169                    exit_code: status.code(),
170                    stderr: String::new(),
171                    agent_name: "Gemini".to_string(),
172                }
173                .into());
174            }
175            Ok(None)
176        } else if self.capture_output {
177            let text = crate::process::run_captured(&mut cmd, "Gemini").await?;
178            log::debug!("Gemini raw response ({} bytes): {}", text.len(), text);
179            Ok(Some(AgentOutput::from_text("gemini", &text)))
180        } else {
181            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
182            crate::process::run_with_captured_stderr(&mut cmd).await?;
183            Ok(None)
184        }
185    }
186}
187
188#[cfg(test)]
189#[path = "gemini_tests.rs"]
190mod tests;
191
192impl Default for Gemini {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198impl GeminiLiveLogAdapter {
199    pub fn new(ctx: LiveLogContext) -> Self {
200        Self {
201            ctx,
202            session_path: None,
203            emitted_message_ids: HashSet::new(),
204        }
205    }
206
207    fn discover_session_path(&self) -> Option<std::path::PathBuf> {
208        let gemini_tmp = tmp_dir()?;
209        let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
210        let projects = std::fs::read_dir(gemini_tmp).ok()?;
211        for project in projects.flatten() {
212            let chats = project.path().join("chats");
213            let files = std::fs::read_dir(chats).ok()?;
214            for file in files.flatten() {
215                let path = file.path();
216                let metadata = file.metadata().ok()?;
217                let modified = metadata.modified().ok()?;
218                let started_at = std::time::SystemTime::UNIX_EPOCH
219                    + std::time::Duration::from_secs(self.ctx.started_at.timestamp().max(0) as u64);
220                if modified < started_at {
221                    continue;
222                }
223                if best
224                    .as_ref()
225                    .map(|(current, _)| modified > *current)
226                    .unwrap_or(true)
227                {
228                    best = Some((modified, path));
229                }
230            }
231        }
232        best.map(|(_, path)| path)
233    }
234}
235
236#[async_trait]
237impl LiveLogAdapter for GeminiLiveLogAdapter {
238    async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
239        if self.session_path.is_none() {
240            self.session_path = self.discover_session_path();
241            if let Some(path) = &self.session_path {
242                writer.add_source_path(path.to_string_lossy().to_string())?;
243            }
244        }
245        let Some(path) = self.session_path.as_ref() else {
246            return Ok(());
247        };
248        let content = match std::fs::read_to_string(path) {
249            Ok(content) => content,
250            Err(_) => return Ok(()),
251        };
252        let json: serde_json::Value = match serde_json::from_str(&content) {
253            Ok(json) => json,
254            Err(_) => {
255                writer.emit(
256                    LogSourceKind::ProviderFile,
257                    LogEventKind::ParseWarning {
258                        message: "Failed to parse Gemini chat file".to_string(),
259                        raw: None,
260                    },
261                )?;
262                return Ok(());
263            }
264        };
265        if let Some(session_id) = json.get("sessionId").and_then(|value| value.as_str()) {
266            writer.set_provider_session_id(Some(session_id.to_string()))?;
267        }
268        if let Some(messages) = json.get("messages").and_then(|value| value.as_array()) {
269            for message in messages {
270                let message_id = message
271                    .get("id")
272                    .and_then(|value| value.as_str())
273                    .unwrap_or_default()
274                    .to_string();
275                if message_id.is_empty() || !self.emitted_message_ids.insert(message_id.clone()) {
276                    continue;
277                }
278                match message.get("type").and_then(|value| value.as_str()) {
279                    Some("user") => writer.emit(
280                        LogSourceKind::ProviderFile,
281                        LogEventKind::UserMessage {
282                            role: "user".to_string(),
283                            content: message
284                                .get("content")
285                                .and_then(|value| value.as_str())
286                                .unwrap_or_default()
287                                .to_string(),
288                            message_id: Some(message_id.clone()),
289                        },
290                    )?,
291                    Some("gemini") => {
292                        writer.emit(
293                            LogSourceKind::ProviderFile,
294                            LogEventKind::AssistantMessage {
295                                content: message
296                                    .get("content")
297                                    .and_then(|value| value.as_str())
298                                    .unwrap_or_default()
299                                    .to_string(),
300                                message_id: Some(message_id.clone()),
301                            },
302                        )?;
303                        if let Some(thoughts) =
304                            message.get("thoughts").and_then(|value| value.as_array())
305                        {
306                            for thought in thoughts {
307                                writer.emit(
308                                    LogSourceKind::ProviderFile,
309                                    LogEventKind::Reasoning {
310                                        content: thought
311                                            .get("description")
312                                            .and_then(|value| value.as_str())
313                                            .unwrap_or_default()
314                                            .to_string(),
315                                        message_id: Some(message_id.clone()),
316                                    },
317                                )?;
318                            }
319                        }
320                        writer.emit(
321                            LogSourceKind::ProviderFile,
322                            LogEventKind::ProviderStatus {
323                                message: "Gemini message metadata".to_string(),
324                                data: Some(serde_json::json!({
325                                    "tokens": message.get("tokens"),
326                                    "model": message.get("model"),
327                                })),
328                            },
329                        )?;
330                    }
331                    _ => {}
332                }
333            }
334        }
335
336        Ok(())
337    }
338}
339
340impl HistoricalLogAdapter for GeminiHistoricalLogAdapter {
341    fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
342        let mut sessions = Vec::new();
343        let Some(gemini_tmp) = tmp_dir() else {
344            return Ok(sessions);
345        };
346        let projects = match std::fs::read_dir(gemini_tmp) {
347            Ok(projects) => projects,
348            Err(_) => return Ok(sessions),
349        };
350        for project in projects.flatten() {
351            let chats = project.path().join("chats");
352            let files = match std::fs::read_dir(chats) {
353                Ok(files) => files,
354                Err(_) => continue,
355            };
356            for file in files.flatten() {
357                let path = file.path();
358                info!("Scanning Gemini history: {}", path.display());
359                let content = match std::fs::read_to_string(&path) {
360                    Ok(content) => content,
361                    Err(_) => continue,
362                };
363                let json: serde_json::Value = match serde_json::from_str(&content) {
364                    Ok(json) => json,
365                    Err(_) => continue,
366                };
367                let Some(session_id) = json.get("sessionId").and_then(|value| value.as_str())
368                else {
369                    continue;
370                };
371                let mut events = Vec::new();
372                if let Some(messages) = json.get("messages").and_then(|value| value.as_array()) {
373                    for message in messages {
374                        let message_id = message
375                            .get("id")
376                            .and_then(|value| value.as_str())
377                            .map(str::to_string);
378                        match message.get("type").and_then(|value| value.as_str()) {
379                            Some("user") => events.push((
380                                LogSourceKind::Backfill,
381                                LogEventKind::UserMessage {
382                                    role: "user".to_string(),
383                                    content: message
384                                        .get("content")
385                                        .and_then(|value| value.as_str())
386                                        .unwrap_or_default()
387                                        .to_string(),
388                                    message_id: message_id.clone(),
389                                },
390                            )),
391                            Some("gemini") => {
392                                events.push((
393                                    LogSourceKind::Backfill,
394                                    LogEventKind::AssistantMessage {
395                                        content: message
396                                            .get("content")
397                                            .and_then(|value| value.as_str())
398                                            .unwrap_or_default()
399                                            .to_string(),
400                                        message_id: message_id.clone(),
401                                    },
402                                ));
403                                if let Some(thoughts) =
404                                    message.get("thoughts").and_then(|value| value.as_array())
405                                {
406                                    for thought in thoughts {
407                                        events.push((
408                                            LogSourceKind::Backfill,
409                                            LogEventKind::Reasoning {
410                                                content: thought
411                                                    .get("description")
412                                                    .and_then(|value| value.as_str())
413                                                    .unwrap_or_default()
414                                                    .to_string(),
415                                                message_id: message_id.clone(),
416                                            },
417                                        ));
418                                    }
419                                }
420                            }
421                            _ => {}
422                        }
423                    }
424                }
425                sessions.push(BackfilledSession {
426                    metadata: SessionLogMetadata {
427                        provider: "gemini".to_string(),
428                        wrapper_session_id: session_id.to_string(),
429                        provider_session_id: Some(session_id.to_string()),
430                        workspace_path: None,
431                        command: "backfill".to_string(),
432                        model: None,
433                        resumed: false,
434                        backfilled: true,
435                    },
436                    completeness: LogCompleteness::Full,
437                    source_paths: vec![path.to_string_lossy().to_string()],
438                    events,
439                });
440            }
441        }
442        Ok(sessions)
443    }
444}
445
446#[async_trait]
447impl Agent for Gemini {
448    fn name(&self) -> &str {
449        "gemini"
450    }
451
452    fn default_model() -> &'static str {
453        DEFAULT_MODEL
454    }
455
456    fn model_for_size(size: ModelSize) -> &'static str {
457        match size {
458            ModelSize::Small => "gemini-3.1-flash-lite-preview",
459            ModelSize::Medium => "gemini-2.5-flash",
460            ModelSize::Large => "gemini-3.1-pro-preview",
461        }
462    }
463
464    fn available_models() -> &'static [&'static str] {
465        AVAILABLE_MODELS
466    }
467
468    fn system_prompt(&self) -> &str {
469        &self.system_prompt
470    }
471
472    fn set_system_prompt(&mut self, prompt: String) {
473        self.system_prompt = prompt;
474    }
475
476    fn get_model(&self) -> &str {
477        &self.model
478    }
479
480    fn set_model(&mut self, model: String) {
481        self.model = model;
482    }
483
484    fn set_root(&mut self, root: String) {
485        self.root = Some(root);
486    }
487
488    fn set_skip_permissions(&mut self, skip: bool) {
489        self.skip_permissions = skip;
490    }
491
492    fn set_output_format(&mut self, format: Option<String>) {
493        self.output_format = format;
494    }
495
496    fn set_add_dirs(&mut self, dirs: Vec<String>) {
497        self.add_dirs = dirs;
498    }
499
500    fn set_env_vars(&mut self, vars: Vec<(String, String)>) {
501        self.env_vars = vars;
502    }
503
504    fn set_capture_output(&mut self, capture: bool) {
505        self.capture_output = capture;
506    }
507
508    fn set_sandbox(&mut self, config: SandboxConfig) {
509        self.sandbox = Some(config);
510    }
511
512    fn set_max_turns(&mut self, turns: u32) {
513        self.max_turns = Some(turns);
514    }
515
516    fn as_any_ref(&self) -> &dyn std::any::Any {
517        self
518    }
519
520    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
521        self
522    }
523
524    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
525        self.execute(false, prompt).await
526    }
527
528    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
529        self.execute(true, prompt).await?;
530        Ok(())
531    }
532
533    async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
534        let mut args = Vec::new();
535
536        if let Some(id) = session_id {
537            args.extend(["--resume".to_string(), id.to_string()]);
538        } else {
539            args.extend(["--resume".to_string(), "latest".to_string()]);
540        }
541
542        if self.skip_permissions {
543            args.extend(["--approval-mode", "yolo"].map(String::from));
544        }
545
546        if !self.model.is_empty() && self.model != "auto" {
547            args.extend(["--model".to_string(), self.model.clone()]);
548        }
549
550        for dir in &self.add_dirs {
551            args.extend(["--include-directories".to_string(), dir.clone()]);
552        }
553
554        let mut cmd = self.make_command(args);
555
556        cmd.stdin(Stdio::inherit())
557            .stdout(Stdio::inherit())
558            .stderr(Stdio::inherit());
559
560        let status = cmd
561            .status()
562            .await
563            .context("Failed to execute 'gemini' CLI. Is it installed and in PATH?")?;
564        if !status.success() {
565            return Err(crate::process::ProcessError {
566                exit_code: status.code(),
567                stderr: String::new(),
568                agent_name: "Gemini".to_string(),
569            }
570            .into());
571        }
572        Ok(())
573    }
574
575    async fn cleanup(&self) -> Result<()> {
576        log::debug!("Cleaning up Gemini agent resources");
577        let base = self.get_base_path();
578        let gemini_dir = base.join(".gemini");
579        let system_file = gemini_dir.join("system.md");
580
581        if system_file.exists() {
582            fs::remove_file(&system_file).await?;
583        }
584
585        if gemini_dir.exists()
586            && fs::read_dir(&gemini_dir)
587                .await?
588                .next_entry()
589                .await?
590                .is_none()
591        {
592            fs::remove_dir(&gemini_dir).await?;
593        }
594
595        Ok(())
596    }
597}