Skip to main content

agentforge_parser/
detect.rs

1use agentforge_core::{AgentFileFormat, AgentForgeError, Result};
2
3/// Detect the format of an agent file from its raw content.
4/// Detection order: JSON → Copilot .agent.md frontmatter → YAML field sniffing
5pub fn detect_format(content: &str) -> Result<AgentFileFormat> {
6    let trimmed = content.trim();
7
8    // If it parses as JSON, classify it
9    if trimmed.starts_with('{') {
10        let value: serde_json::Value = serde_json::from_str(trimmed)
11            .map_err(|e| AgentForgeError::ParseError(format!("Invalid JSON: {e}")))?;
12
13        return Ok(classify_json_format(&value));
14    }
15
16    // Markdown frontmatter: --- ... ---
17    if trimmed.starts_with("---") {
18        let yaml_body = extract_frontmatter(trimmed)?;
19        let value: serde_json::Value = serde_yaml::from_str(&yaml_body)
20            .map_err(|e| AgentForgeError::ParseError(format!("Invalid frontmatter YAML: {e}")))?;
21
22        // Copilot .agent.md: has `name` in frontmatter but NOT `agentforge_schema_version`,
23        // and the body after the closing `---` contains non-trivial Markdown content.
24        let has_name = value.get("name").is_some();
25        let is_native = value.get("agentforge_schema_version").is_some();
26        let has_markdown_body = has_markdown_body_after_frontmatter(trimmed);
27
28        if has_name && !is_native && has_markdown_body {
29            return Ok(AgentFileFormat::CopilotAgentMd);
30        }
31
32        return Ok(classify_yaml_format(&value));
33    }
34
35    // YAML
36    if let Ok(value) = serde_yaml::from_str::<serde_json::Value>(trimmed) {
37        return Ok(classify_yaml_format(&value));
38    }
39
40    Err(AgentForgeError::InvalidFormat(
41        "Cannot detect format: not valid JSON, YAML, or Markdown frontmatter".to_string(),
42    ))
43}
44
45/// Return true when the content after the closing `---` contains non-trivial Markdown.
46fn has_markdown_body_after_frontmatter(content: &str) -> bool {
47    let mut parts = content.splitn(3, "---");
48    parts.next(); // empty prefix
49    parts.next(); // frontmatter
50    match parts.next() {
51        Some(body) => !body.trim().is_empty(),
52        None => false,
53    }
54}
55
56fn classify_json_format(v: &serde_json::Value) -> AgentFileFormat {
57    // OpenAI Assistants API: has "instructions" and "tools" array at root, no "system_prompt"
58    if v.get("instructions").is_some()
59        && v.get("tools").is_some()
60        && v.get("system_prompt").is_none()
61    {
62        return AgentFileFormat::OpenaiJson;
63    }
64
65    // Anthropic: has "system" key at root with "tools" or "tool_choice"
66    if (v.get("system").is_some() || v.get("system_prompt").is_some())
67        && v.get("model").is_some()
68        && v.get("agentforge_schema_version").is_none()
69    {
70        // Check for Anthropic model names
71        if let Some(model) = v.get("model").and_then(|m| m.as_str()) {
72            if model.contains("claude") {
73                return AgentFileFormat::AnthropicJson;
74            }
75        }
76        return AgentFileFormat::AnthropicJson;
77    }
78
79    // Native AgentForge JSON
80    AgentFileFormat::NativeYaml
81}
82
83fn classify_yaml_format(v: &serde_json::Value) -> AgentFileFormat {
84    // Native AgentForge YAML: has agentforge_schema_version
85    if v.get("agentforge_schema_version").is_some() {
86        return AgentFileFormat::NativeYaml;
87    }
88
89    // LangChain: has "_type" field set to "langchain" or similar
90    if let Some(t) = v.get("_type").and_then(|t| t.as_str()) {
91        if t.contains("langchain") || t.contains("lang_chain") {
92            return AgentFileFormat::LangchainYaml;
93        }
94    }
95
96    // CrewAI: has "agents" array at root or "role" + "goal" + "backstory"
97    if v.get("agents").is_some()
98        || (v.get("role").is_some() && v.get("goal").is_some() && v.get("backstory").is_some())
99    {
100        return AgentFileFormat::CrewaiYaml;
101    }
102
103    // Default to native YAML
104    AgentFileFormat::NativeYaml
105}
106
107fn extract_frontmatter(content: &str) -> Result<String> {
108    let parts: Vec<&str> = content.splitn(3, "---").collect();
109    if parts.len() < 3 {
110        return Err(AgentForgeError::ParseError(
111            "Malformed Markdown frontmatter: missing closing ---".to_string(),
112        ));
113    }
114    Ok(parts[1].to_string())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn detects_native_yaml() {
123        let content = r#"
124agentforge_schema_version: "1"
125name: test-agent
126version: "1.0.0"
127"#;
128        assert_eq!(detect_format(content).unwrap(), AgentFileFormat::NativeYaml);
129    }
130
131    #[test]
132    fn detects_openai_json() {
133        let content = r#"{"instructions": "You are helpful.", "tools": [], "model": "gpt-4o"}"#;
134        assert_eq!(detect_format(content).unwrap(), AgentFileFormat::OpenaiJson);
135    }
136
137    #[test]
138    fn detects_anthropic_json() {
139        let content =
140            r#"{"system": "You are helpful.", "tools": [], "model": "claude-3-5-sonnet-20241022"}"#;
141        assert_eq!(
142            detect_format(content).unwrap(),
143            AgentFileFormat::AnthropicJson
144        );
145    }
146
147    #[test]
148    fn detects_crewai_yaml() {
149        let content = r#"
150role: Support Agent
151goal: Help customers
152backstory: You are an expert support agent.
153"#;
154        assert_eq!(detect_format(content).unwrap(), AgentFileFormat::CrewaiYaml);
155    }
156
157    #[test]
158    fn detects_markdown_frontmatter() {
159        let content = r#"---
160agentforge_schema_version: "1"
161name: test-agent
162version: "1.0.0"
163---
164# Documentation
165"#;
166        assert_eq!(detect_format(content).unwrap(), AgentFileFormat::NativeYaml);
167    }
168
169    #[test]
170    fn detects_copilot_agent_md() {
171        let content = r#"---
172name: 'GitHub Actions Expert'
173description: 'CI/CD workflow specialist'
174model: GPT-4.1
175tools: ['github/*', 'read']
176---
177
178# GitHub Actions Expert
179
180You are a GitHub Actions specialist.
181"#;
182        assert_eq!(
183            detect_format(content).unwrap(),
184            AgentFileFormat::CopilotAgentMd
185        );
186    }
187
188    #[test]
189    fn copilot_md_without_body_falls_through_to_yaml() {
190        // No Markdown body — treated as plain YAML (NativeYaml default)
191        let content = "---\nname: 'Just Frontmatter'\n---\n";
192        // Empty body so NOT CopilotAgentMd — falls through to NativeYaml
193        assert_eq!(detect_format(content).unwrap(), AgentFileFormat::NativeYaml);
194    }
195
196    #[test]
197    fn rejects_invalid_json() {
198        let result = detect_format("{invalid json}");
199        assert!(result.is_err());
200    }
201}