Skip to main content

agentspec_provider/local/
gemini.rs

1use crate::error::ProviderError;
2use crate::ir::{Capability, GIT_READONLY_COMMANDS, ProviderConfig, Sandbox, SandboxTranslator};
3use crate::types::{AiEvent, AiProvider, AiRequest, AiResponse};
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use std::io::Write as _;
7use tokio::io::{AsyncBufReadExt, BufReader};
8use tokio::process::Command;
9use tokio::sync::mpsc;
10
11use super::json;
12
13/// Configuration for the Gemini CLI provider.
14#[derive(Default)]
15pub(crate) struct GeminiConfig {
16    pub model: Option<String>,
17    pub sandbox: Option<Sandbox>,
18    pub debug: bool,
19}
20
21pub struct GeminiProvider {
22    config: GeminiConfig,
23}
24
25impl GeminiProvider {
26    pub(crate) fn new(config: GeminiConfig) -> Self {
27        Self { config }
28    }
29
30    pub(crate) fn from_provider_config(config: &ProviderConfig) -> Self {
31        Self::new(GeminiConfig {
32            model: config.model.clone(),
33            sandbox: config.sandbox.clone(),
34            debug: config.debug,
35        })
36    }
37
38    fn apply_sandbox(&self, cmd: &mut Command) -> Result<Option<tempfile::NamedTempFile>> {
39        if let Some(sandbox) = &self.config.sandbox {
40            let policy = self.translate_sandbox_to_toml(sandbox);
41            if policy.is_empty() {
42                return Ok(None);
43            }
44            let mut file =
45                tempfile::NamedTempFile::new().context("failed to create policy temp file")?;
46            file.write_all(policy.as_bytes())
47                .context("failed to write policy file")?;
48            cmd.arg("--sandbox").arg("--policy").arg(file.path());
49            Ok(Some(file))
50        } else {
51            Ok(None)
52        }
53    }
54
55    async fn request_streaming(
56        &self,
57        req: &AiRequest,
58        events: mpsc::UnboundedSender<AiEvent>,
59    ) -> Result<AiResponse> {
60        let prompt = build_prompt(req);
61
62        let mut cmd = Command::new("gemini");
63        cmd.current_dir(&req.working_dir)
64            .arg("--prompt")
65            .arg(&prompt)
66            .arg("--output-format")
67            .arg("stream-json");
68
69        // Keep the temp file alive until the process finishes.
70        let _policy_file = self.apply_sandbox(&mut cmd)?;
71
72        if let Some(model) = &self.config.model {
73            cmd.arg("--model").arg(model);
74        }
75
76        if self.config.debug {
77            eprintln!(
78                "[DEBUG] gemini stream-json (model={})",
79                self.config.model.as_deref().unwrap_or("default")
80            );
81        }
82
83        let mut child = cmd
84            .stdout(std::process::Stdio::piped())
85            .stderr(std::process::Stdio::piped())
86            .spawn()
87            .context("failed to run gemini CLI")?;
88
89        let stdout = child.stdout.take().unwrap();
90        let stderr_handle = child.stderr.take().unwrap();
91
92        let stderr_task = tokio::spawn(async move {
93            let mut buf = String::new();
94            let _ = tokio::io::AsyncReadExt::read_to_string(
95                &mut BufReader::new(stderr_handle),
96                &mut buf,
97            )
98            .await;
99            buf
100        });
101
102        let mut reader = BufReader::new(stdout).lines();
103        let mut result_text = String::new();
104
105        while let Ok(Some(line)) = reader.next_line().await {
106            let event: serde_json::Value = match serde_json::from_str(line.trim()) {
107                Ok(v) => v,
108                Err(_) => continue,
109            };
110
111            super::claude::parse_tool_calls(&event, &events);
112
113            if event.get("type").and_then(|t| t.as_str()) == Some("result")
114                && let Some(r) = event.get("result")
115            {
116                let raw = match r {
117                    serde_json::Value::String(s) => s.clone(),
118                    _ => r.to_string(),
119                };
120                result_text = json::extract_json(&raw).unwrap_or(raw);
121            }
122        }
123
124        let stderr_text = stderr_task.await.unwrap_or_default();
125        let status = child.wait().await?;
126
127        if !status.success() {
128            anyhow::bail!(ProviderError::BackendFailed(format!(
129                "gemini CLI failed (exit {}): {}",
130                status,
131                stderr_text.trim()
132            )));
133        }
134
135        if result_text.is_empty() {
136            anyhow::bail!(ProviderError::ParseResponse(
137                "no result in gemini stream".into()
138            ));
139        }
140
141        Ok(AiResponse {
142            text: result_text,
143            usage: None,
144        })
145    }
146
147    async fn request_batch(&self, req: &AiRequest) -> Result<AiResponse> {
148        let prompt = build_prompt(req);
149
150        let mut cmd = Command::new("gemini");
151        cmd.current_dir(&req.working_dir)
152            .arg("--prompt")
153            .arg(&prompt);
154
155        let _policy_file = self.apply_sandbox(&mut cmd)?;
156
157        if let Some(model) = &self.config.model {
158            cmd.arg("--model").arg(model);
159        }
160
161        if self.config.debug {
162            eprintln!(
163                "[DEBUG] gemini (model={})",
164                self.config.model.as_deref().unwrap_or("default")
165            );
166        }
167
168        let output = cmd.output().await.context("failed to run gemini CLI")?;
169        let raw = String::from_utf8_lossy(&output.stdout).to_string();
170        let stderr = String::from_utf8_lossy(&output.stderr);
171
172        if self.config.debug {
173            eprintln!("[DEBUG] exit: {}", output.status);
174            eprintln!("[DEBUG] stdout (first 500): {}", &raw[..raw.len().min(500)]);
175            if !stderr.is_empty() {
176                eprintln!("[DEBUG] stderr: {stderr}");
177            }
178        }
179
180        if !output.status.success() {
181            anyhow::bail!(ProviderError::BackendFailed(format!(
182                "gemini CLI failed (exit {}): {}",
183                output.status,
184                stderr.trim()
185            )));
186        }
187
188        let text = json::extract_json(&raw).unwrap_or(raw);
189        Ok(AiResponse { text, usage: None })
190    }
191}
192
193impl GeminiProvider {
194    /// Convert a Sandbox into a Gemini-native TOML policy string.
195    fn translate_sandbox_to_toml(&self, sandbox: &Sandbox) -> String {
196        let tools = self.translate_sandbox(sandbox);
197        if tools.is_empty() {
198            return String::new();
199        }
200
201        let mut toml = String::new();
202
203        // Process allowed capabilities
204        for cap in &sandbox.allowed {
205            if sandbox.denied.contains(cap) {
206                continue;
207            }
208            match cap {
209                Capability::ReadFile => {
210                    toml.push_str(
211                        "# Allow file reading, searching, and listing\n\
212                         [[rule]]\n\
213                         toolName = [\"read_file\", \"glob\", \"grep_search\", \"list_directory\"]\n\
214                         decision = \"allow\"\n\
215                         priority = 100\n\n",
216                    );
217                }
218                Capability::GitReadOnly => {
219                    let cmds: Vec<_> = GIT_READONLY_COMMANDS
220                        .iter()
221                        .map(|c| c.to_string())
222                        .collect();
223                    let regex = format!("^git ({})", cmds.join("|"));
224                    toml.push_str(&format!(
225                        "# Allow read-only git commands\n\
226                         [[rule]]\n\
227                         toolName = \"run_shell_command\"\n\
228                         commandRegex = \"{regex}\"\n\
229                         decision = \"allow\"\n\
230                         priority = 100\n\n",
231                    ));
232                }
233                Capability::ShellCommand { pattern } => {
234                    toml.push_str(&format!(
235                        "[[rule]]\n\
236                         toolName = \"run_shell_command\"\n\
237                         commandRegex = \"{pattern}\"\n\
238                         decision = \"allow\"\n\
239                         priority = 100\n\n",
240                    ));
241                }
242                Capability::Custom(s) => {
243                    toml.push_str(s);
244                    toml.push('\n');
245                }
246                Capability::WriteFile | Capability::Network => {}
247            }
248        }
249
250        // Process denied capabilities
251        for cap in &sandbox.denied {
252            match cap {
253                Capability::WriteFile => {
254                    toml.push_str(
255                        "# Deny file writing/editing\n\
256                         [[rule]]\n\
257                         toolName = [\"write_file\", \"replace\"]\n\
258                         decision = \"deny\"\n\
259                         priority = 50\n\
260                         denyMessage = \"Write operations are not permitted.\"\n\n",
261                    );
262                }
263                Capability::ShellCommand { .. } => {
264                    toml.push_str(
265                        "# Deny shell commands\n\
266                         [[rule]]\n\
267                         toolName = \"run_shell_command\"\n\
268                         decision = \"deny\"\n\
269                         priority = 50\n\
270                         denyMessage = \"Only read-only git commands are permitted.\"\n\n",
271                    );
272                }
273                Capability::Custom(s) => {
274                    toml.push_str(s);
275                    toml.push('\n');
276                }
277                _ => {}
278            }
279        }
280
281        // Catch-all deny
282        toml.push_str(
283            "# Deny everything else\n\
284             [[rule]]\n\
285             toolName = \"*\"\n\
286             decision = \"deny\"\n\
287             priority = 10\n\
288             denyMessage = \"Only read-only operations are allowed.\"\n",
289        );
290
291        toml
292    }
293}
294
295impl SandboxTranslator for GeminiProvider {
296    fn translate_allowed(&self, capabilities: &[Capability]) -> Vec<String> {
297        // Gemini uses TOML policy, not individual tool entries.
298        // Return a non-empty marker so translate_sandbox knows there are restrictions.
299        if capabilities.is_empty() {
300            Vec::new()
301        } else {
302            vec!["__gemini_has_policy__".into()]
303        }
304    }
305}
306
307#[async_trait]
308impl AiProvider for GeminiProvider {
309    fn name(&self) -> &str {
310        "gemini"
311    }
312
313    async fn is_available(&self) -> bool {
314        Command::new("gemini")
315            .arg("--help")
316            .output()
317            .await
318            .is_ok_and(|o| o.status.success())
319    }
320
321    async fn request(
322        &self,
323        req: &AiRequest,
324        events: Option<mpsc::UnboundedSender<AiEvent>>,
325    ) -> Result<AiResponse> {
326        match events {
327            Some(tx) => self.request_streaming(req, tx).await,
328            None => self.request_batch(req).await,
329        }
330    }
331}
332
333fn build_prompt(req: &AiRequest) -> String {
334    let mut prompt = format!("{}\n\n", req.system_prompt);
335
336    if let Some(schema) = &req.json_schema {
337        prompt.push_str(&format!(
338            "You MUST respond with valid JSON matching this schema:\n```json\n{schema}\n```\n\n\
339             Respond ONLY with the JSON object, no markdown fences, no explanation.\n\n"
340        ));
341    }
342
343    prompt.push_str(&req.user_prompt);
344    prompt
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn translate_sr_sandbox_to_toml() {
353        let provider = GeminiProvider::new(GeminiConfig::default());
354        let sandbox = Sandbox {
355            allowed: vec![Capability::GitReadOnly, Capability::ReadFile],
356            denied: vec![
357                Capability::WriteFile,
358                Capability::ShellCommand {
359                    pattern: ".*".into(),
360                },
361            ],
362        };
363        let toml = provider.translate_sandbox_to_toml(&sandbox);
364        assert!(toml.contains("read_file"));
365        assert!(toml.contains("commandRegex"));
366        assert!(toml.contains("write_file"));
367        assert!(toml.contains("toolName = \"*\""));
368    }
369
370    #[test]
371    fn empty_sandbox_produces_no_policy() {
372        let provider = GeminiProvider::new(GeminiConfig::default());
373        let sandbox = Sandbox::default();
374        let toml = provider.translate_sandbox_to_toml(&sandbox);
375        assert!(toml.is_empty());
376    }
377}