agentspec_provider/local/
copilot.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 tokio::process::Command;
7use tokio::sync::mpsc;
8
9use super::json;
10
11pub(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}