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