Skip to main content

agentforge_parser/formats/
copilot.rs

1use agentforge_core::{AgentFile, EvalHints, ModelConfig, ModelProvider, Result, ToolDefinition};
2use std::collections::HashMap;
3
4/// Normalize a GitHub Copilot `.agent.md` file into `AgentFile`.
5///
6/// Format: YAML frontmatter (`---`) followed by Markdown body.
7/// Frontmatter fields:
8///   - `name`         — agent display name (required)
9///   - `description`  — short description (optional)
10///   - `model`        — LLM model string, e.g. "GPT-4.1", "claude-sonnet-4-5" (optional)
11///   - `tools`        — list of capability strings like "read", "github/*" (optional)
12///
13/// The Markdown body after the frontmatter becomes `system_prompt`.
14pub fn normalize(frontmatter: &serde_json::Value, system_prompt_body: &str) -> Result<AgentFile> {
15    let name = frontmatter
16        .get("name")
17        .and_then(|v| v.as_str())
18        .unwrap_or("copilot-agent")
19        .to_string();
20
21    let description = frontmatter
22        .get("description")
23        .and_then(|v| v.as_str())
24        .map(String::from);
25
26    // System prompt is the full Markdown body
27    let system_prompt = system_prompt_body.trim().to_string();
28
29    // Model: infer provider from model string
30    let model = parse_model(frontmatter);
31
32    // Tools: Copilot tools are capability reference strings (e.g. "github/*", "read"),
33    // not structured tool definitions. We store them as minimal ToolDefinitions so they
34    // appear in the agent representation and can be used in scenario generation.
35    let tools = parse_copilot_tools(frontmatter);
36
37    // Build metadata preserving Copilot-specific fields
38    let mut metadata: HashMap<String, serde_json::Value> = HashMap::new();
39    if let Some(desc) = &description {
40        metadata.insert(
41            "description".to_string(),
42            serde_json::Value::String(desc.clone()),
43        );
44    }
45    if let Some(arg_hint) = frontmatter.get("argument-hint").and_then(|v| v.as_str()) {
46        metadata.insert(
47            "argument_hint".to_string(),
48            serde_json::Value::String(arg_hint.to_string()),
49        );
50    }
51    if let Some(handoffs) = frontmatter.get("handoffs") {
52        metadata.insert("handoffs".to_string(), handoffs.clone());
53    }
54    if let Some(mcp_servers) = frontmatter.get("mcp-servers") {
55        metadata.insert("mcp_servers".to_string(), mcp_servers.clone());
56    }
57
58    Ok(AgentFile {
59        agentforge_schema_version: "1".to_string(),
60        name,
61        version: "1.0.0".to_string(),
62        model,
63        system_prompt,
64        tools,
65        output_schema: None,
66        constraints: vec![],
67        eval_hints: Some(EvalHints::default()),
68        metadata: if metadata.is_empty() {
69            None
70        } else {
71            Some(metadata)
72        },
73    })
74}
75
76/// Parse the `model` frontmatter field into a `ModelConfig`.
77/// Supports model strings like "GPT-4.1", "gpt-4o", "claude-sonnet-4-5", "o3", etc.
78fn parse_model(frontmatter: &serde_json::Value) -> ModelConfig {
79    let model_str = frontmatter
80        .get("model")
81        .and_then(|v| v.as_str())
82        .unwrap_or("gpt-4o");
83
84    let lower = model_str.to_lowercase();
85
86    let (provider, model_id) = if lower.contains("claude") || lower.contains("anthropic") {
87        (ModelProvider::Anthropic, model_str.to_string())
88    } else if lower.contains("ollama") || lower.starts_with("ollama/") {
89        let id = model_str.strip_prefix("ollama/").unwrap_or(model_str);
90        (ModelProvider::Ollama, id.to_string())
91    } else {
92        // Default: OpenAI (covers gpt-*, o1, o3, GPT-4.1, etc.)
93        (ModelProvider::Openai, model_str.to_string())
94    };
95
96    ModelConfig {
97        provider,
98        model_id,
99        temperature: None,
100        max_tokens: None,
101        top_p: None,
102    }
103}
104
105/// Parse Copilot tool capability strings into minimal `ToolDefinition` entries.
106///
107/// Copilot tools are references like `"read"`, `"github/*"`, `"context7/*"`, not full schemas.
108/// We create lightweight ToolDefinitions so the rest of AgentForge can reason about them.
109fn parse_copilot_tools(frontmatter: &serde_json::Value) -> Vec<ToolDefinition> {
110    let tools_val = match frontmatter.get("tools") {
111        Some(t) => t,
112        None => return vec![],
113    };
114
115    let tool_refs: Vec<String> = match tools_val {
116        serde_json::Value::Array(arr) => arr
117            .iter()
118            .filter_map(|v| v.as_str().map(String::from))
119            .collect(),
120        serde_json::Value::String(s) => vec![s.clone()],
121        _ => return vec![],
122    };
123
124    tool_refs
125        .into_iter()
126        .map(|capability| {
127            // Derive a human-readable name: "github/*" → "github", "read/readFile" → "readFile"
128            let display_name = capability
129                .rsplit('/')
130                .next()
131                .map(|s| {
132                    if s == "*" {
133                        capability
134                            .split('/')
135                            .next()
136                            .unwrap_or(&capability)
137                            .to_string()
138                    } else {
139                        s.to_string()
140                    }
141                })
142                .unwrap_or_else(|| capability.clone());
143
144            let (description, parameters) = capability_schema(&capability, &display_name);
145
146            ToolDefinition {
147                name: display_name,
148                description,
149                parameters,
150            }
151        })
152        .collect()
153}
154
155/// Return a (description, parameters JSON schema) pair for a Copilot capability string.
156///
157/// Provides meaningful parameter schemas so LLMs can actually call these tools during
158/// eval scenarios, rather than refusing because `properties: {}` signals no parameters.
159fn capability_schema(capability: &str, display_name: &str) -> (String, serde_json::Value) {
160    // Normalise to lowercase leaf for pattern matching
161    let leaf = display_name.to_lowercase();
162
163    match leaf.as_str() {
164        // GitHub API — flexible query/action parameter
165        "github" => (
166            "Interact with GitHub: search repositories, list files, read file contents, \
167             search code, list issues, pull requests, workflows, and other GitHub API operations."
168                .to_string(),
169            serde_json::json!({
170                "type": "object",
171                "properties": {
172                    "query": {
173                        "type": "string",
174                        "description": "The GitHub operation or search query to perform \
175                                        (e.g. 'list workflow files in .github/workflows/', \
176                                        'search code for TODO', 'get file contents of README.md')."
177                    }
178                },
179                "required": ["query"],
180                "x-copilot-capability": capability
181            }),
182        ),
183
184        // File search
185        "filesearch" | "file_search" => (
186            "Search the repository for files matching a name pattern or glob.".to_string(),
187            serde_json::json!({
188                "type": "object",
189                "properties": {
190                    "query": {
191                        "type": "string",
192                        "description": "Filename pattern, glob, or partial path to search for \
193                                        (e.g. '*.yml', '.github/workflows/*.yml', 'Dockerfile')."
194                    }
195                },
196                "required": ["query"],
197                "x-copilot-capability": capability
198            }),
199        ),
200
201        // Codebase / semantic search
202        "codebase" | "search_codebase" | "searchcodebase" => (
203            "Semantically search the codebase for relevant code, functions, or patterns."
204                .to_string(),
205            serde_json::json!({
206                "type": "object",
207                "properties": {
208                    "query": {
209                        "type": "string",
210                        "description": "Natural-language or keyword search query to find \
211                                        relevant code in the workspace."
212                    }
213                },
214                "required": ["query"],
215                "x-copilot-capability": capability
216            }),
217        ),
218
219        // Read file
220        "readfile" | "read_file" => (
221            "Read the contents of a file in the repository.".to_string(),
222            serde_json::json!({
223                "type": "object",
224                "properties": {
225                    "file_path": {
226                        "type": "string",
227                        "description": "Relative path to the file to read \
228                                        (e.g. '.github/workflows/ci.yml')."
229                    }
230                },
231                "required": ["file_path"],
232                "x-copilot-capability": capability
233            }),
234        ),
235
236        // Edit / write files
237        "editfiles" | "edit_files" => (
238            "Create or edit one or more files in the repository.".to_string(),
239            serde_json::json!({
240                "type": "object",
241                "properties": {
242                    "file_path": {
243                        "type": "string",
244                        "description": "Relative path to the file to create or modify."
245                    },
246                    "content": {
247                        "type": "string",
248                        "description": "Full new content to write to the file."
249                    }
250                },
251                "required": ["file_path", "content"],
252                "x-copilot-capability": capability
253            }),
254        ),
255
256        // Run terminal command
257        "runinterminal" | "run_in_terminal" | "terminal" => (
258            "Execute a shell command in the project terminal.".to_string(),
259            serde_json::json!({
260                "type": "object",
261                "properties": {
262                    "command": {
263                        "type": "string",
264                        "description": "Shell command to run (e.g. 'actionlint .github/workflows/ci.yml')."
265                    }
266                },
267                "required": ["command"],
268                "x-copilot-capability": capability
269            }),
270        ),
271
272        // Fallback: generic single-input tool
273        _ => (
274            format!("Copilot capability: {capability}"),
275            serde_json::json!({
276                "type": "object",
277                "properties": {
278                    "input": {
279                        "type": "string",
280                        "description": format!("Input for the {display_name} capability.")
281                    }
282                },
283                "required": ["input"],
284                "x-copilot-capability": capability
285            }),
286        ),
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn normalizes_basic_copilot_agent() {
296        let frontmatter = serde_json::json!({
297            "name": "GitHub Actions Expert",
298            "description": "Specialist in secure CI/CD workflows",
299            "model": "GPT-4.1",
300            "tools": ["github/*", "search/codebase", "edit/editFiles"]
301        });
302        let body = "# GitHub Actions Expert\n\nYou help teams build secure workflows.";
303
304        let agent = normalize(&frontmatter, body).unwrap();
305
306        assert_eq!(agent.name, "GitHub Actions Expert");
307        assert_eq!(agent.model.model_id, "GPT-4.1");
308        assert_eq!(agent.model.provider, ModelProvider::Openai);
309        assert!(agent.system_prompt.contains("GitHub Actions Expert"));
310        assert_eq!(agent.tools.len(), 3);
311        assert_eq!(agent.tools[0].name, "github");
312        assert_eq!(agent.tools[1].name, "codebase");
313        assert_eq!(agent.tools[2].name, "editFiles");
314        // Each tool must have at least one required parameter so models can call them
315        for tool in &agent.tools {
316            let props = tool
317                .parameters
318                .get("properties")
319                .and_then(|p| p.as_object());
320            assert!(
321                props.map(|p| !p.is_empty()).unwrap_or(false),
322                "Tool '{}' must have non-empty properties",
323                tool.name
324            );
325        }
326    }
327
328    #[test]
329    fn normalizes_claude_model() {
330        let frontmatter = serde_json::json!({
331            "name": "Claude Agent",
332            "model": "claude-sonnet-4-5"
333        });
334        let agent = normalize(&frontmatter, "You are helpful.").unwrap();
335        assert_eq!(agent.model.provider, ModelProvider::Anthropic);
336        assert_eq!(agent.model.model_id, "claude-sonnet-4-5");
337    }
338
339    #[test]
340    fn defaults_model_when_absent() {
341        let frontmatter = serde_json::json!({ "name": "No Model Agent" });
342        let agent = normalize(&frontmatter, "Do stuff.").unwrap();
343        assert_eq!(agent.model.model_id, "gpt-4o");
344        assert_eq!(agent.model.provider, ModelProvider::Openai);
345    }
346
347    #[test]
348    fn stores_description_in_metadata() {
349        let frontmatter = serde_json::json!({
350            "name": "Test",
351            "description": "A helpful test agent"
352        });
353        let agent = normalize(&frontmatter, "System prompt.").unwrap();
354        let meta = agent.metadata.unwrap();
355        assert_eq!(
356            meta["description"],
357            serde_json::Value::String("A helpful test agent".to_string())
358        );
359    }
360
361    #[test]
362    fn empty_tools_yields_no_tool_definitions() {
363        let frontmatter = serde_json::json!({ "name": "No Tools" });
364        let agent = normalize(&frontmatter, "Prompt.").unwrap();
365        assert!(agent.tools.is_empty());
366    }
367
368    /// Regression: every known Copilot capability must produce a non-empty
369    /// `properties` map so models can call the tool with real arguments.
370    /// Previously all tools had `properties: {}` which caused models to refuse
371    /// to invoke them (no parameters = no-op tool).
372    #[test]
373    fn all_known_capabilities_have_non_empty_schemas() {
374        let capabilities = [
375            "github/*",
376            "search/fileSearch",
377            "search/codebase",
378            "read/readFile",
379            "edit/editFiles",
380            "execute/runInTerminal",
381        ];
382        let frontmatter = serde_json::json!({
383            "name": "Full Agent",
384            "tools": capabilities
385        });
386        let agent = normalize(&frontmatter, "Prompt.").unwrap();
387        assert_eq!(agent.tools.len(), capabilities.len());
388        for tool in &agent.tools {
389            let props = tool
390                .parameters
391                .get("properties")
392                .and_then(|p| p.as_object())
393                .unwrap_or_else(|| panic!("'{}' must have a properties object", tool.name));
394            assert!(
395                !props.is_empty(),
396                "Tool '{}' has empty properties — models cannot call it",
397                tool.name
398            );
399            let required = tool
400                .parameters
401                .get("required")
402                .and_then(|r| r.as_array())
403                .unwrap_or_else(|| panic!("'{}' must have a required array", tool.name));
404            assert!(
405                !required.is_empty(),
406                "Tool '{}' has no required fields — models may skip it",
407                tool.name
408            );
409        }
410    }
411
412    /// Unknown/custom capabilities should fall back to a generic `input` field,
413    /// not an empty schema.
414    #[test]
415    fn unknown_capability_falls_back_to_generic_schema() {
416        let frontmatter = serde_json::json!({
417            "name": "Custom Agent",
418            "tools": ["custom/myTool", "context7/*"]
419        });
420        let agent = normalize(&frontmatter, "Prompt.").unwrap();
421        for tool in &agent.tools {
422            let props = tool
423                .parameters
424                .get("properties")
425                .and_then(|p| p.as_object())
426                .unwrap_or_else(|| panic!("'{}' must have properties", tool.name));
427            assert!(
428                !props.is_empty(),
429                "Fallback tool '{}' must not have empty properties",
430                tool.name
431            );
432        }
433    }
434}