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