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