1use 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
16pub 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 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 if let Some(p) = prompt {
87 args.push("--".to_string());
90 args.push(p.to_string());
91 }
92
93 args
94 }
95
96 fn make_command(&self, agent_args: Vec<String>) -> Command {
98 self.common.make_command("gemini", agent_args)
99 }
100
101 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 {
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 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}