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