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    /// Build args for `--resume <id> -- <prompt>` headless invocation.
102    ///
103    /// Mirrors `build_run_args(false, Some(prompt))` plus `--resume <id>`.
104    /// The trailing `--` ends option parsing so prompts beginning with `-`
105    /// aren't misread as flags by the gemini CLI. Used by
106    /// [`Agent::run_resume_with_prompt`] for auto-resume after a detected
107    /// usage limit.
108    fn build_resume_args(&self, session_id: &str, prompt: &str) -> Vec<String> {
109        let mut args = Vec::new();
110
111        if self.common.skip_permissions {
112            args.extend(["--approval-mode", "yolo"].map(String::from));
113        }
114
115        if !self.common.model.is_empty() && self.common.model != "auto" {
116            args.extend(["--model".to_string(), self.common.model.clone()]);
117        }
118
119        for dir in &self.common.add_dirs {
120            args.extend(["--include-directories".to_string(), dir.clone()]);
121        }
122
123        if let Some(ref format) = self.common.output_format {
124            args.extend(["--output-format".to_string(), format.clone()]);
125        }
126
127        args.extend(["--resume".to_string(), session_id.to_string()]);
128        args.push("--".to_string());
129        args.push(prompt.to_string());
130        args
131    }
132
133    async fn execute(
134        &self,
135        interactive: bool,
136        prompt: Option<&str>,
137    ) -> Result<Option<AgentOutput>> {
138        if !self.common.system_prompt.is_empty() {
139            log::debug!(
140                "Gemini system prompt (written to system.md): {}",
141                self.common.system_prompt
142            );
143            self.write_system_file().await?;
144        }
145
146        let agent_args = self.build_run_args(interactive, prompt);
147        log::debug!("Gemini command: gemini {}", agent_args.join(" "));
148        if let Some(p) = prompt {
149            log::debug!("Gemini user prompt: {p}");
150        }
151        let mut cmd = self.make_command(agent_args);
152
153        if !self.common.system_prompt.is_empty() {
154            cmd.env("GEMINI_SYSTEM_MD", "true");
155        }
156
157        if interactive {
158            CommonAgentState::run_interactive_command_with_hook(
159                &mut cmd,
160                "Gemini",
161                self.common.on_spawn_hook.as_ref(),
162            )
163            .await?;
164            Ok(None)
165        } else {
166            self.common
167                .run_non_interactive_simple(&mut cmd, "Gemini")
168                .await
169        }
170    }
171}
172
173#[cfg(test)]
174#[path = "gemini_tests.rs"]
175mod tests;
176
177impl Default for Gemini {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183impl GeminiLiveLogAdapter {
184    pub fn new(ctx: LiveLogContext) -> Self {
185        Self {
186            ctx,
187            session_path: None,
188            emitted_message_ids: HashSet::new(),
189        }
190    }
191
192    fn discover_session_path(&self) -> Option<std::path::PathBuf> {
193        let gemini_tmp = tmp_dir()?;
194        let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
195        let projects = std::fs::read_dir(gemini_tmp).ok()?;
196        for project in projects.flatten() {
197            let chats = project.path().join("chats");
198            let files = std::fs::read_dir(chats).ok()?;
199            for file in files.flatten() {
200                let path = file.path();
201                let metadata = file.metadata().ok()?;
202                let modified = metadata.modified().ok()?;
203                let started_at = std::time::SystemTime::UNIX_EPOCH
204                    + std::time::Duration::from_secs(self.ctx.started_at.timestamp().max(0) as u64);
205                if modified < started_at {
206                    continue;
207                }
208                if best
209                    .as_ref()
210                    .map(|(current, _)| modified > *current)
211                    .unwrap_or(true)
212                {
213                    best = Some((modified, path));
214                }
215            }
216        }
217        best.map(|(_, path)| path)
218    }
219}
220
221#[async_trait]
222impl LiveLogAdapter for GeminiLiveLogAdapter {
223    async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
224        if self.session_path.is_none() {
225            self.session_path = self.discover_session_path();
226            if let Some(path) = &self.session_path {
227                writer.add_source_path(path.to_string_lossy().to_string())?;
228            }
229        }
230        let Some(path) = self.session_path.as_ref() else {
231            return Ok(());
232        };
233        let content = match std::fs::read_to_string(path) {
234            Ok(content) => content,
235            Err(_) => return Ok(()),
236        };
237        let json: serde_json::Value = match serde_json::from_str(&content) {
238            Ok(json) => json,
239            Err(_) => {
240                writer.emit(
241                    LogSourceKind::ProviderFile,
242                    LogEventKind::ParseWarning {
243                        message: "Failed to parse Gemini chat file".to_string(),
244                        raw: None,
245                    },
246                )?;
247                return Ok(());
248            }
249        };
250        if let Some(session_id) = json.get("sessionId").and_then(|value| value.as_str()) {
251            writer.set_provider_session_id(Some(session_id.to_string()))?;
252        }
253        // Scan the whole chat blob for a Gemini 429 / RESOURCE_EXHAUSTED
254        // envelope. The canonical signal lives on stderr, but some Gemini
255        // versions also leak it into the chat file as a system message, and
256        // user-supplied `extra_patterns` may match arbitrary content. Dedup
257        // by the matched substring so we don't re-emit on every poll cycle.
258        {
259            let cfg = crate::usage_limits::UsageLimitConfig::default();
260            if let Some(hit) = crate::providers::gemini_usage_limits::detect_text(&content, &cfg) {
261                let key = format!("usage_limit:{}", hit.raw);
262                if self.emitted_message_ids.insert(key) {
263                    writer.emit(
264                        LogSourceKind::ProviderFile,
265                        crate::usage_limits::to_log_event_hit(hit),
266                    )?;
267                }
268            }
269        }
270
271        if let Some(messages) = json.get("messages").and_then(|value| value.as_array()) {
272            for message in messages {
273                let message_id = message
274                    .get("id")
275                    .and_then(|value| value.as_str())
276                    .unwrap_or_default()
277                    .to_string();
278                if message_id.is_empty() || !self.emitted_message_ids.insert(message_id.clone()) {
279                    continue;
280                }
281                match message.get("type").and_then(|value| value.as_str()) {
282                    Some("user") => writer.emit(
283                        LogSourceKind::ProviderFile,
284                        LogEventKind::UserMessage {
285                            role: "user".to_string(),
286                            content: message
287                                .get("content")
288                                .and_then(|value| value.as_str())
289                                .unwrap_or_default()
290                                .to_string(),
291                            message_id: Some(message_id.clone()),
292                        },
293                    )?,
294                    Some("gemini") => {
295                        writer.emit(
296                            LogSourceKind::ProviderFile,
297                            LogEventKind::AssistantMessage {
298                                content: message
299                                    .get("content")
300                                    .and_then(|value| value.as_str())
301                                    .unwrap_or_default()
302                                    .to_string(),
303                                message_id: Some(message_id.clone()),
304                            },
305                        )?;
306                        if let Some(thoughts) =
307                            message.get("thoughts").and_then(|value| value.as_array())
308                        {
309                            for thought in thoughts {
310                                writer.emit(
311                                    LogSourceKind::ProviderFile,
312                                    LogEventKind::Reasoning {
313                                        content: thought
314                                            .get("description")
315                                            .and_then(|value| value.as_str())
316                                            .unwrap_or_default()
317                                            .to_string(),
318                                        message_id: Some(message_id.clone()),
319                                    },
320                                )?;
321                            }
322                        }
323                        writer.emit(
324                            LogSourceKind::ProviderFile,
325                            LogEventKind::ProviderStatus {
326                                message: "Gemini message metadata".to_string(),
327                                data: Some(serde_json::json!({
328                                    "tokens": message.get("tokens"),
329                                    "model": message.get("model"),
330                                })),
331                            },
332                        )?;
333                    }
334                    _ => {}
335                }
336            }
337        }
338
339        Ok(())
340    }
341}
342
343impl HistoricalLogAdapter for GeminiHistoricalLogAdapter {
344    fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
345        let mut sessions = Vec::new();
346        let Some(gemini_tmp) = tmp_dir() else {
347            return Ok(sessions);
348        };
349        let projects = match std::fs::read_dir(gemini_tmp) {
350            Ok(projects) => projects,
351            Err(_) => return Ok(sessions),
352        };
353        for project in projects.flatten() {
354            let chats = project.path().join("chats");
355            let files = match std::fs::read_dir(chats) {
356                Ok(files) => files,
357                Err(_) => continue,
358            };
359            for file in files.flatten() {
360                let path = file.path();
361                info!("Scanning Gemini history: {}", path.display());
362                let content = match std::fs::read_to_string(&path) {
363                    Ok(content) => content,
364                    Err(_) => continue,
365                };
366                let json: serde_json::Value = match serde_json::from_str(&content) {
367                    Ok(json) => json,
368                    Err(_) => continue,
369                };
370                let Some(session_id) = json.get("sessionId").and_then(|value| value.as_str())
371                else {
372                    continue;
373                };
374                let mut events = Vec::new();
375                if let Some(messages) = json.get("messages").and_then(|value| value.as_array()) {
376                    for message in messages {
377                        let message_id = message
378                            .get("id")
379                            .and_then(|value| value.as_str())
380                            .map(str::to_string);
381                        match message.get("type").and_then(|value| value.as_str()) {
382                            Some("user") => events.push((
383                                LogSourceKind::Backfill,
384                                LogEventKind::UserMessage {
385                                    role: "user".to_string(),
386                                    content: message
387                                        .get("content")
388                                        .and_then(|value| value.as_str())
389                                        .unwrap_or_default()
390                                        .to_string(),
391                                    message_id: message_id.clone(),
392                                },
393                            )),
394                            Some("gemini") => {
395                                events.push((
396                                    LogSourceKind::Backfill,
397                                    LogEventKind::AssistantMessage {
398                                        content: message
399                                            .get("content")
400                                            .and_then(|value| value.as_str())
401                                            .unwrap_or_default()
402                                            .to_string(),
403                                        message_id: message_id.clone(),
404                                    },
405                                ));
406                                if let Some(thoughts) =
407                                    message.get("thoughts").and_then(|value| value.as_array())
408                                {
409                                    for thought in thoughts {
410                                        events.push((
411                                            LogSourceKind::Backfill,
412                                            LogEventKind::Reasoning {
413                                                content: thought
414                                                    .get("description")
415                                                    .and_then(|value| value.as_str())
416                                                    .unwrap_or_default()
417                                                    .to_string(),
418                                                message_id: message_id.clone(),
419                                            },
420                                        ));
421                                    }
422                                }
423                            }
424                            _ => {}
425                        }
426                    }
427                }
428                sessions.push(BackfilledSession {
429                    metadata: SessionLogMetadata {
430                        provider: "gemini".to_string(),
431                        wrapper_session_id: session_id.to_string(),
432                        provider_session_id: Some(session_id.to_string()),
433                        workspace_path: None,
434                        command: "backfill".to_string(),
435                        model: None,
436                        resumed: false,
437                        backfilled: true,
438                    },
439                    completeness: LogCompleteness::Full,
440                    source_paths: vec![path.to_string_lossy().to_string()],
441                    events,
442                });
443            }
444        }
445        Ok(sessions)
446    }
447}
448
449#[async_trait]
450impl Agent for Gemini {
451    fn name(&self) -> &str {
452        "gemini"
453    }
454
455    fn default_model() -> &'static str {
456        DEFAULT_MODEL
457    }
458
459    fn model_for_size(size: ModelSize) -> &'static str {
460        match size {
461            ModelSize::Small => "gemini-3.1-flash-lite-preview",
462            ModelSize::Medium => "gemini-2.5-flash",
463            ModelSize::Large => "gemini-3.1-pro-preview",
464        }
465    }
466
467    fn available_models() -> &'static [&'static str] {
468        AVAILABLE_MODELS
469    }
470
471    crate::providers::common::impl_common_agent_setters!();
472
473    fn set_skip_permissions(&mut self, skip: bool) {
474        self.common.skip_permissions = skip;
475    }
476
477    crate::providers::common::impl_as_any!();
478
479    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
480        self.execute(false, prompt).await
481    }
482
483    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
484        self.execute(true, prompt).await?;
485        Ok(())
486    }
487
488    async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
489        let mut args = Vec::new();
490
491        if let Some(id) = session_id {
492            args.extend(["--resume".to_string(), id.to_string()]);
493        } else {
494            args.extend(["--resume".to_string(), "latest".to_string()]);
495        }
496
497        if self.common.skip_permissions {
498            args.extend(["--approval-mode", "yolo"].map(String::from));
499        }
500
501        if !self.common.model.is_empty() && self.common.model != "auto" {
502            args.extend(["--model".to_string(), self.common.model.clone()]);
503        }
504
505        for dir in &self.common.add_dirs {
506            args.extend(["--include-directories".to_string(), dir.clone()]);
507        }
508
509        let mut cmd = self.make_command(args);
510        CommonAgentState::run_interactive_command_with_hook(
511            &mut cmd,
512            "Gemini",
513            self.common.on_spawn_hook.as_ref(),
514        )
515        .await
516    }
517
518    async fn run_resume_with_prompt(
519        &self,
520        session_id: &str,
521        prompt: &str,
522    ) -> Result<Option<AgentOutput>> {
523        log::debug!("Gemini resume with prompt: session={session_id}, prompt={prompt}");
524
525        if !self.common.system_prompt.is_empty() {
526            self.write_system_file().await?;
527        }
528
529        let args = self.build_resume_args(session_id, prompt);
530        let mut cmd = self.make_command(args);
531
532        if !self.common.system_prompt.is_empty() {
533            cmd.env("GEMINI_SYSTEM_MD", "true");
534        }
535
536        self.common
537            .run_non_interactive_simple(&mut cmd, "Gemini")
538            .await
539    }
540
541    /// Cheap startup probe that runs `gemini --version` with a short
542    /// timeout. This catches common "binary exists but can't launch"
543    /// failures (broken node install, missing dynamic deps, etc.) without
544    /// consuming any API quota. Auth failures only surface during real
545    /// invocations, so they are picked up later by the run() path.
546    async fn probe(&self) -> Result<()> {
547        use anyhow::Context;
548        use std::time::Duration;
549        let probe = async {
550            let out = Command::new("gemini")
551                .arg("--version")
552                .output()
553                .await
554                .context("failed to launch 'gemini --version'")?;
555            if !out.status.success() {
556                let stderr = String::from_utf8_lossy(&out.stderr);
557                anyhow::bail!(
558                    "'gemini --version' exited with {}: {}",
559                    out.status,
560                    stderr.trim()
561                );
562            }
563            Ok(())
564        };
565        match tokio::time::timeout(Duration::from_secs(5), probe).await {
566            Ok(res) => res,
567            Err(_) => anyhow::bail!("'gemini --version' timed out after 5s"),
568        }
569    }
570
571    async fn cleanup(&self) -> Result<()> {
572        log::debug!("Cleaning up Gemini agent resources");
573        let base = self.common.get_base_path();
574        let gemini_dir = base.join(".gemini");
575        let system_file = gemini_dir.join("system.md");
576
577        if system_file.exists() {
578            fs::remove_file(&system_file).await?;
579        }
580
581        if gemini_dir.exists()
582            && fs::read_dir(&gemini_dir)
583                .await?
584                .next_entry()
585                .await?
586                .is_none()
587        {
588            fs::remove_dir(&gemini_dir).await?;
589        }
590
591        Ok(())
592    }
593}