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
11#[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}