Skip to main content

agentforge_parser/
parser.rs

1use crate::{detect::detect_format, formats};
2use agentforge_core::{AgentFile, AgentFileFormat, AgentForgeError, AgentVersion, Result};
3use chrono::Utc;
4use sha2::{Digest, Sha256};
5use uuid::Uuid;
6
7/// The result of parsing a raw agent file.
8#[derive(Debug, Clone)]
9pub struct ParsedAgentFile {
10    pub agent: AgentFile,
11    pub format: AgentFileFormat,
12    pub sha: String,
13    pub raw_content: String,
14}
15
16/// Parse a raw agent file string into an `AgentFile`.
17/// Returns `Err` only for fatal parse errors.
18/// Lint warnings are returned via `ValidationResult` from `validate_agent_file`.
19pub fn parse_agent_file(content: &str) -> Result<ParsedAgentFile> {
20    let format = detect_format(content)?;
21
22    // Parse into serde_json::Value first to allow format-specific normalization
23    let value = parse_to_value(content, &format)?;
24
25    let agent = formats::normalize(&format, &value, content)?;
26    let sha = compute_sha256(content);
27
28    Ok(ParsedAgentFile {
29        agent,
30        format,
31        sha,
32        raw_content: content.to_string(),
33    })
34}
35
36/// Like `parse_agent_file` but uses an explicitly supplied format instead of auto-detecting.
37/// Useful when the file extension is misleading or the user wants to override detection.
38pub fn parse_agent_file_with_format(
39    content: &str,
40    format: AgentFileFormat,
41) -> Result<ParsedAgentFile> {
42    let value = parse_to_value(content, &format)?;
43    let agent = formats::normalize(&format, &value, content)?;
44    let sha = compute_sha256(content);
45    Ok(ParsedAgentFile {
46        agent,
47        format,
48        sha,
49        raw_content: content.to_string(),
50    })
51}
52
53/// Build an `AgentVersion` from a `ParsedAgentFile`.
54pub fn to_agent_version(parsed: ParsedAgentFile) -> AgentVersion {
55    let now = Utc::now();
56    AgentVersion {
57        id: Uuid::new_v4(),
58        name: parsed.agent.name.clone(),
59        version: parsed.agent.version.clone(),
60        sha: parsed.sha,
61        file_content: parsed.agent,
62        raw_content: parsed.raw_content,
63        format: parsed.format,
64        promoted: false,
65        is_champion: false,
66        changelog: None,
67        parent_sha: None,
68        created_at: now,
69        updated_at: now,
70    }
71}
72
73fn parse_to_value(content: &str, format: &AgentFileFormat) -> Result<serde_json::Value> {
74    let trimmed = content.trim();
75
76    match format {
77        AgentFileFormat::OpenaiJson | AgentFileFormat::AnthropicJson => {
78            serde_json::from_str(trimmed).map_err(|e| AgentForgeError::ParseError(e.to_string()))
79        }
80        AgentFileFormat::NativeYaml
81        | AgentFileFormat::LangchainYaml
82        | AgentFileFormat::CrewaiYaml => {
83            // Handle Markdown frontmatter
84            let yaml_content = if trimmed.starts_with("---") {
85                extract_frontmatter(trimmed)?
86            } else {
87                trimmed.to_string()
88            };
89            serde_yaml::from_str(&yaml_content)
90                .map_err(|e| AgentForgeError::ParseError(e.to_string()))
91        }
92        AgentFileFormat::CopilotAgentMd => {
93            // Parse only the YAML frontmatter as the value;
94            // the Markdown body is extracted later in normalize() via the raw content.
95            let frontmatter = extract_frontmatter(trimmed)?;
96            serde_yaml::from_str(&frontmatter)
97                .map_err(|e| AgentForgeError::ParseError(e.to_string()))
98        }
99    }
100}
101
102fn extract_frontmatter(content: &str) -> Result<String> {
103    let parts: Vec<&str> = content.splitn(3, "---").collect();
104    if parts.len() < 3 {
105        return Err(AgentForgeError::ParseError(
106            "Malformed Markdown frontmatter: missing closing ---".to_string(),
107        ));
108    }
109    Ok(parts[1].to_string())
110}
111
112/// Compute SHA-256 of the file content for content addressing.
113pub fn compute_sha256(content: &str) -> String {
114    let mut hasher = Sha256::new();
115    hasher.update(content.as_bytes());
116    hex::encode(hasher.finalize())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use indoc::indoc;
123
124    const NATIVE_YAML: &str = indoc! {r#"
125        agentforge_schema_version: "1"
126        name: customer-support-agent
127        version: "1.0.0"
128        model:
129          provider: openai
130          model_id: gpt-4o
131          temperature: 0.2
132          max_tokens: 2048
133        system_prompt: |
134          You are a helpful customer support agent.
135          Never share pricing without verifying entitlement first.
136        tools:
137          - name: get_order_status
138            description: "Retrieve status of a customer order by order ID."
139            parameters:
140              type: object
141              properties:
142                order_id:
143                  type: string
144              required: [order_id]
145        output_schema:
146          type: object
147          properties:
148            response:
149              type: string
150            action_taken:
151              type: string
152          required: [response]
153        constraints:
154          - "Never mention competitor products."
155          - "Always confirm order ID before calling get_order_status."
156        eval_hints:
157          domain: customer_support
158          typical_turns: 3
159          critical_tools: [get_order_status]
160          pass_threshold: 0.85
161          scenario_count: 200
162    "#};
163
164    #[test]
165    fn parses_native_yaml() {
166        let result = parse_agent_file(NATIVE_YAML).unwrap();
167        assert_eq!(result.agent.name, "customer-support-agent");
168        assert_eq!(result.agent.model.model_id, "gpt-4o");
169        assert_eq!(result.agent.tools.len(), 1);
170        assert_eq!(result.agent.constraints.len(), 2);
171        assert_eq!(result.format, AgentFileFormat::NativeYaml);
172        assert!(!result.sha.is_empty());
173    }
174
175    #[test]
176    fn sha_is_deterministic() {
177        let a = compute_sha256("hello");
178        let b = compute_sha256("hello");
179        assert_eq!(a, b);
180    }
181
182    #[test]
183    fn sha_differs_for_different_content() {
184        let a = compute_sha256("hello");
185        let b = compute_sha256("world");
186        assert_ne!(a, b);
187    }
188
189    #[test]
190    fn parses_openai_json() {
191        let content = r#"{
192            "name": "support-bot",
193            "instructions": "You are a helpful support agent.",
194            "model": "gpt-4o",
195            "tools": []
196        }"#;
197        let result = parse_agent_file(content).unwrap();
198        assert_eq!(result.format, AgentFileFormat::OpenaiJson);
199        assert_eq!(
200            result.agent.system_prompt,
201            "You are a helpful support agent."
202        );
203    }
204
205    #[test]
206    fn to_agent_version_sets_fields() {
207        let parsed = parse_agent_file(NATIVE_YAML).unwrap();
208        let version = to_agent_version(parsed);
209        assert_eq!(version.name, "customer-support-agent");
210        assert!(!version.promoted);
211        assert!(!version.is_champion);
212    }
213}