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                anyhow::bail!("Gemini command failed with status: {}", status);
169            }
170            Ok(None)
171        } else if self.capture_output {
172            let text = crate::process::run_captured(&mut cmd, "Gemini").await?;
173            log::debug!("Gemini raw response ({} bytes): {}", text.len(), text);
174            Ok(Some(AgentOutput::from_text("gemini", &text)))
175        } else {
176            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
177            crate::process::run_with_captured_stderr(&mut cmd).await?;
178            Ok(None)
179        }
180    }
181}
182
183#[cfg(test)]
184#[path = "gemini_tests.rs"]
185mod tests;
186
187impl Default for Gemini {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl GeminiLiveLogAdapter {
194    pub fn new(ctx: LiveLogContext) -> Self {
195        Self {
196            ctx,
197            session_path: None,
198            emitted_message_ids: HashSet::new(),
199        }
200    }
201
202    fn discover_session_path(&self) -> Option<std::path::PathBuf> {
203        let gemini_tmp = tmp_dir()?;
204        let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
205        let projects = std::fs::read_dir(gemini_tmp).ok()?;
206        for project in projects.flatten() {
207            let chats = project.path().join("chats");
208            let files = std::fs::read_dir(chats).ok()?;
209            for file in files.flatten() {
210                let path = file.path();
211                let metadata = file.metadata().ok()?;
212                let modified = metadata.modified().ok()?;
213                let started_at = std::time::SystemTime::UNIX_EPOCH
214                    + std::time::Duration::from_secs(self.ctx.started_at.timestamp().max(0) as u64);
215                if modified < started_at {
216                    continue;
217                }
218                if best
219                    .as_ref()
220                    .map(|(current, _)| modified > *current)
221                    .unwrap_or(true)
222                {
223                    best = Some((modified, path));
224                }
225            }
226        }
227        best.map(|(_, path)| path)
228    }
229}
230
231#[async_trait]
232impl LiveLogAdapter for GeminiLiveLogAdapter {
233    async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
234        if self.session_path.is_none() {
235            self.session_path = self.discover_session_path();
236            if let Some(path) = &self.session_path {
237                writer.add_source_path(path.to_string_lossy().to_string())?;
238            }
239        }
240        let Some(path) = self.session_path.as_ref() else {
241            return Ok(());
242        };
243        let content = match std::fs::read_to_string(path) {
244            Ok(content) => content,
245            Err(_) => return Ok(()),
246        };
247        let json: serde_json::Value = match serde_json::from_str(&content) {
248            Ok(json) => json,
249            Err(_) => {
250                writer.emit(
251                    LogSourceKind::ProviderFile,
252                    LogEventKind::ParseWarning {
253                        message: "Failed to parse Gemini chat file".to_string(),
254                        raw: None,
255                    },
256                )?;
257                return Ok(());
258            }
259        };
260        if let Some(session_id) = json.get("sessionId").and_then(|value| value.as_str()) {
261            writer.set_provider_session_id(Some(session_id.to_string()))?;
262        }
263        if let Some(messages) = json.get("messages").and_then(|value| value.as_array()) {
264            for message in messages {
265                let message_id = message
266                    .get("id")
267                    .and_then(|value| value.as_str())
268                    .unwrap_or_default()
269                    .to_string();
270                if message_id.is_empty() || !self.emitted_message_ids.insert(message_id.clone()) {
271                    continue;
272                }
273                match message.get("type").and_then(|value| value.as_str()) {
274                    Some("user") => writer.emit(
275                        LogSourceKind::ProviderFile,
276                        LogEventKind::UserMessage {
277                            role: "user".to_string(),
278                            content: message
279                                .get("content")
280                                .and_then(|value| value.as_str())
281                                .unwrap_or_default()
282                                .to_string(),
283                            message_id: Some(message_id.clone()),
284                        },
285                    )?,
286                    Some("gemini") => {
287                        writer.emit(
288                            LogSourceKind::ProviderFile,
289                            LogEventKind::AssistantMessage {
290                                content: message
291                                    .get("content")
292                                    .and_then(|value| value.as_str())
293                                    .unwrap_or_default()
294                                    .to_string(),
295                                message_id: Some(message_id.clone()),
296                            },
297                        )?;
298                        if let Some(thoughts) =
299                            message.get("thoughts").and_then(|value| value.as_array())
300                        {
301                            for thought in thoughts {
302                                writer.emit(
303                                    LogSourceKind::ProviderFile,
304                                    LogEventKind::Reasoning {
305                                        content: thought
306                                            .get("description")
307                                            .and_then(|value| value.as_str())
308                                            .unwrap_or_default()
309                                            .to_string(),
310                                        message_id: Some(message_id.clone()),
311                                    },
312                                )?;
313                            }
314                        }
315                        writer.emit(
316                            LogSourceKind::ProviderFile,
317                            LogEventKind::ProviderStatus {
318                                message: "Gemini message metadata".to_string(),
319                                data: Some(serde_json::json!({
320                                    "tokens": message.get("tokens"),
321                                    "model": message.get("model"),
322                                })),
323                            },
324                        )?;
325                    }
326                    _ => {}
327                }
328            }
329        }
330
331        Ok(())
332    }
333}
334
335impl HistoricalLogAdapter for GeminiHistoricalLogAdapter {
336    fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
337        let mut sessions = Vec::new();
338        let Some(gemini_tmp) = tmp_dir() else {
339            return Ok(sessions);
340        };
341        let projects = match std::fs::read_dir(gemini_tmp) {
342            Ok(projects) => projects,
343            Err(_) => return Ok(sessions),
344        };
345        for project in projects.flatten() {
346            let chats = project.path().join("chats");
347            let files = match std::fs::read_dir(chats) {
348                Ok(files) => files,
349                Err(_) => continue,
350            };
351            for file in files.flatten() {
352                let path = file.path();
353                info!("Scanning Gemini history: {}", path.display());
354                let content = match std::fs::read_to_string(&path) {
355                    Ok(content) => content,
356                    Err(_) => continue,
357                };
358                let json: serde_json::Value = match serde_json::from_str(&content) {
359                    Ok(json) => json,
360                    Err(_) => continue,
361                };
362                let Some(session_id) = json.get("sessionId").and_then(|value| value.as_str())
363                else {
364                    continue;
365                };
366                let mut events = Vec::new();
367                if let Some(messages) = json.get("messages").and_then(|value| value.as_array()) {
368                    for message in messages {
369                        let message_id = message
370                            .get("id")
371                            .and_then(|value| value.as_str())
372                            .map(str::to_string);
373                        match message.get("type").and_then(|value| value.as_str()) {
374                            Some("user") => events.push((
375                                LogSourceKind::Backfill,
376                                LogEventKind::UserMessage {
377                                    role: "user".to_string(),
378                                    content: message
379                                        .get("content")
380                                        .and_then(|value| value.as_str())
381                                        .unwrap_or_default()
382                                        .to_string(),
383                                    message_id: message_id.clone(),
384                                },
385                            )),
386                            Some("gemini") => {
387                                events.push((
388                                    LogSourceKind::Backfill,
389                                    LogEventKind::AssistantMessage {
390                                        content: message
391                                            .get("content")
392                                            .and_then(|value| value.as_str())
393                                            .unwrap_or_default()
394                                            .to_string(),
395                                        message_id: message_id.clone(),
396                                    },
397                                ));
398                                if let Some(thoughts) =
399                                    message.get("thoughts").and_then(|value| value.as_array())
400                                {
401                                    for thought in thoughts {
402                                        events.push((
403                                            LogSourceKind::Backfill,
404                                            LogEventKind::Reasoning {
405                                                content: thought
406                                                    .get("description")
407                                                    .and_then(|value| value.as_str())
408                                                    .unwrap_or_default()
409                                                    .to_string(),
410                                                message_id: message_id.clone(),
411                                            },
412                                        ));
413                                    }
414                                }
415                            }
416                            _ => {}
417                        }
418                    }
419                }
420                sessions.push(BackfilledSession {
421                    metadata: SessionLogMetadata {
422                        provider: "gemini".to_string(),
423                        wrapper_session_id: session_id.to_string(),
424                        provider_session_id: Some(session_id.to_string()),
425                        workspace_path: None,
426                        command: "backfill".to_string(),
427                        model: None,
428                        resumed: false,
429                        backfilled: true,
430                    },
431                    completeness: LogCompleteness::Full,
432                    source_paths: vec![path.to_string_lossy().to_string()],
433                    events,
434                });
435            }
436        }
437        Ok(sessions)
438    }
439}
440
441#[async_trait]
442impl Agent for Gemini {
443    fn name(&self) -> &str {
444        "gemini"
445    }
446
447    fn default_model() -> &'static str {
448        DEFAULT_MODEL
449    }
450
451    fn model_for_size(size: ModelSize) -> &'static str {
452        match size {
453            ModelSize::Small => "gemini-3.1-flash-lite-preview",
454            ModelSize::Medium => "gemini-2.5-flash",
455            ModelSize::Large => "gemini-3.1-pro-preview",
456        }
457    }
458
459    fn available_models() -> &'static [&'static str] {
460        AVAILABLE_MODELS
461    }
462
463    fn system_prompt(&self) -> &str {
464        &self.system_prompt
465    }
466
467    fn set_system_prompt(&mut self, prompt: String) {
468        self.system_prompt = prompt;
469    }
470
471    fn get_model(&self) -> &str {
472        &self.model
473    }
474
475    fn set_model(&mut self, model: String) {
476        self.model = model;
477    }
478
479    fn set_root(&mut self, root: String) {
480        self.root = Some(root);
481    }
482
483    fn set_skip_permissions(&mut self, skip: bool) {
484        self.skip_permissions = skip;
485    }
486
487    fn set_output_format(&mut self, format: Option<String>) {
488        self.output_format = format;
489    }
490
491    fn set_add_dirs(&mut self, dirs: Vec<String>) {
492        self.add_dirs = dirs;
493    }
494
495    fn set_env_vars(&mut self, vars: Vec<(String, String)>) {
496        self.env_vars = vars;
497    }
498
499    fn set_capture_output(&mut self, capture: bool) {
500        self.capture_output = capture;
501    }
502
503    fn set_sandbox(&mut self, config: SandboxConfig) {
504        self.sandbox = Some(config);
505    }
506
507    fn set_max_turns(&mut self, turns: u32) {
508        self.max_turns = Some(turns);
509    }
510
511    fn as_any_ref(&self) -> &dyn std::any::Any {
512        self
513    }
514
515    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
516        self
517    }
518
519    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
520        self.execute(false, prompt).await
521    }
522
523    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
524        self.execute(true, prompt).await?;
525        Ok(())
526    }
527
528    async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
529        let mut args = Vec::new();
530
531        if let Some(id) = session_id {
532            args.extend(["--resume".to_string(), id.to_string()]);
533        } else {
534            args.extend(["--resume".to_string(), "latest".to_string()]);
535        }
536
537        if self.skip_permissions {
538            args.extend(["--approval-mode", "yolo"].map(String::from));
539        }
540
541        if !self.model.is_empty() && self.model != "auto" {
542            args.extend(["--model".to_string(), self.model.clone()]);
543        }
544
545        for dir in &self.add_dirs {
546            args.extend(["--include-directories".to_string(), dir.clone()]);
547        }
548
549        let mut cmd = self.make_command(args);
550
551        cmd.stdin(Stdio::inherit())
552            .stdout(Stdio::inherit())
553            .stderr(Stdio::inherit());
554
555        let status = cmd
556            .status()
557            .await
558            .context("Failed to execute 'gemini' CLI. Is it installed and in PATH?")?;
559        if !status.success() {
560            anyhow::bail!("Gemini resume failed with status: {}", status);
561        }
562        Ok(())
563    }
564
565    async fn cleanup(&self) -> Result<()> {
566        log::debug!("Cleaning up Gemini agent resources");
567        let base = self.get_base_path();
568        let gemini_dir = base.join(".gemini");
569        let system_file = gemini_dir.join("system.md");
570
571        if system_file.exists() {
572            fs::remove_file(&system_file).await?;
573        }
574
575        if gemini_dir.exists()
576            && fs::read_dir(&gemini_dir)
577                .await?
578                .next_entry()
579                .await?
580                .is_none()
581        {
582            fs::remove_dir(&gemini_dir).await?;
583        }
584
585        Ok(())
586    }
587}