Skip to main content

agentspec_provider/local/
copilot.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 tokio::process::Command;
7use tokio::sync::mpsc;
8
9use super::json;
10
11/// Configuration for the Copilot CLI provider.
12pub(crate) struct CopilotConfig {
13    pub model: Option<String>,
14    pub sandbox: Option<Sandbox>,
15    pub debug: bool,
16}
17
18impl Default for CopilotConfig {
19    fn default() -> Self {
20        Self {
21            model: None,
22            sandbox: None,
23            debug: false,
24        }
25    }
26}
27
28pub struct CopilotProvider {
29    config: CopilotConfig,
30}
31
32impl CopilotProvider {
33    pub(crate) fn new(config: CopilotConfig) -> Self {
34        Self { config }
35    }
36
37    pub(crate) fn from_provider_config(config: &ProviderConfig) -> Self {
38        Self::new(CopilotConfig {
39            model: config.model.clone(),
40            sandbox: config.sandbox.clone(),
41            debug: config.debug,
42        })
43    }
44}
45
46impl SandboxTranslator for CopilotProvider {
47    fn translate_allowed(&self, capabilities: &[Capability]) -> Vec<String> {
48        let mut tools = Vec::new();
49        for cap in capabilities {
50            match cap {
51                Capability::GitReadOnly => {
52                    for cmd in GIT_READONLY_COMMANDS {
53                        tools.push(format!("shell(git:{cmd})"));
54                    }
55                }
56                Capability::ShellCommand { pattern } => {
57                    tools.push(format!("shell({pattern})"));
58                }
59                Capability::Custom(s) => tools.push(s.clone()),
60                Capability::ReadFile | Capability::WriteFile | Capability::Network => {}
61            }
62        }
63        tools
64    }
65}
66
67fn build_system_prompt(base: &str, json_schema: Option<&str>) -> String {
68    match json_schema {
69        Some(schema) => format!(
70            "{base}\n\n\
71             You MUST respond with valid JSON matching this schema:\n\
72             ```json\n{schema}\n```\n\n\
73             Respond ONLY with the JSON object, no markdown fences, no explanation."
74        ),
75        None => base.to_string(),
76    }
77}
78
79#[async_trait]
80impl AiProvider for CopilotProvider {
81    fn name(&self) -> &str {
82        "copilot"
83    }
84
85    async fn is_available(&self) -> bool {
86        Command::new("gh")
87            .args(["copilot", "--version"])
88            .output()
89            .await
90            .is_ok_and(|o| o.status.success())
91    }
92
93    async fn request(
94        &self,
95        req: &AiRequest,
96        _events: Option<mpsc::UnboundedSender<AiEvent>>,
97    ) -> Result<AiResponse> {
98        let model = self.config.model.as_deref().unwrap_or("gpt-4.1");
99        let system = build_system_prompt(&req.system_prompt, req.json_schema.as_deref());
100        let combined_prompt = format!("{system}\n\n{}", req.user_prompt);
101
102        let mut cmd = Command::new("gh");
103        cmd.current_dir(&req.working_dir)
104            .arg("copilot")
105            .arg("-p")
106            .arg(&combined_prompt)
107            .arg("-s")
108            .arg("--model")
109            .arg(model);
110
111        if let Some(sandbox) = &self.config.sandbox {
112            for tool in self.translate_sandbox(sandbox) {
113                cmd.arg("--allow-tool").arg(tool);
114            }
115        }
116
117        cmd.arg("--no-custom-instructions").arg("--autopilot");
118
119        if self.config.debug {
120            eprintln!("[DEBUG] gh copilot (model={model})");
121        }
122
123        let output = cmd.output().await.context("failed to run gh copilot")?;
124        let raw = String::from_utf8_lossy(&output.stdout).to_string();
125        let stderr = String::from_utf8_lossy(&output.stderr);
126
127        if self.config.debug {
128            eprintln!("[DEBUG] exit: {}", output.status);
129            eprintln!(
130                "[DEBUG] stdout (first 500): {}",
131                &raw[..raw.len().min(500)]
132            );
133            if !stderr.is_empty() {
134                eprintln!("[DEBUG] stderr: {stderr}");
135            }
136        }
137
138        if !output.status.success() {
139            anyhow::bail!(ProviderError::BackendFailed(format!(
140                "gh copilot failed (exit {}): {}",
141                output.status,
142                stderr.trim()
143            )));
144        }
145
146        let text = json::extract_json(&raw).unwrap_or(raw);
147        Ok(AiResponse { text, usage: None })
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn translate_git_readonly() {
157        let provider = CopilotProvider::new(CopilotConfig::default());
158        let caps = vec![Capability::GitReadOnly];
159        let tools = provider.translate_allowed(&caps);
160        assert!(tools.contains(&"shell(git:diff)".to_string()));
161        assert!(tools.contains(&"shell(git:log)".to_string()));
162        assert_eq!(tools.len(), GIT_READONLY_COMMANDS.len());
163    }
164
165    #[test]
166    fn translate_custom_passthrough() {
167        let provider = CopilotProvider::new(CopilotConfig::default());
168        let caps = vec![Capability::Custom("shell(npm:test)".into())];
169        let tools = provider.translate_allowed(&caps);
170        assert_eq!(tools, vec!["shell(npm:test)"]);
171    }
172}