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