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 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 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}