agentspec_provider/local/
gemini.rs1use 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#[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 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 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 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 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 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 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}