Skip to main content

zag_agent/providers/
gemini.rs

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