agentforge_parser/formats/
copilot.rs1use agentforge_core::{AgentFile, EvalHints, ModelConfig, ModelProvider, Result, ToolDefinition};
2use std::collections::HashMap;
3
4pub 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 let system_prompt = system_prompt_body.trim().to_string();
28
29 let model = parse_model(frontmatter);
31
32 let tools = parse_copilot_tools(frontmatter);
36
37 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
76fn 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 (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
105fn 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 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
155fn capability_schema(capability: &str, display_name: &str) -> (String, serde_json::Value) {
160 let leaf = display_name.to_lowercase();
162
163 match leaf.as_str() {
164 "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 "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" | "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 "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 "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 "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 _ => (
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 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 #[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 #[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}