car-builder 0.23.0

Natural-language → validated car-workflow manifest builder for Common Agent Runtime
Documentation
//! Model output → [`Workflow`] parser with JSON repair.
//!
//! Mirrors `car-active-planner`'s proposal parser: try a direct parse, then
//! extract the first `{...}` block (handles markdown fences / preamble), then
//! repair common JSON issues (currently: trailing commas).

use car_ir::json_extract::{extract_json_object, repair_json};
use car_workflow::Workflow;

/// Parse model text into a [`Workflow`], tolerating fenced/preambled output.
pub fn parse_workflow(text: &str) -> Result<Workflow, String> {
    let trimmed = text.trim();

    // 1. Direct parse.
    if let Ok(wf) = serde_json::from_str::<Workflow>(trimmed) {
        return Ok(wf);
    }

    // 2. Extract the first JSON object, then 3. repair it.
    if let Some(json) = extract_json_object(trimmed) {
        if let Ok(wf) = serde_json::from_str::<Workflow>(&json) {
            return Ok(wf);
        }
        let repaired = repair_json(&json);
        // Surface serde's error (e.g. "missing field `system_prompt`") so the
        // repair prompt can name the exact defect instead of a generic message.
        return serde_json::from_str::<Workflow>(&repaired).map_err(|e| {
            format!("extracted a JSON object but it is not a valid workflow: {e}")
        });
    }

    Err(format!(
        "no JSON workflow object found in output ({} chars)",
        text.len()
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    const MINIMAL: &str = r#"{
        "id": "wf1",
        "name": "Test",
        "start": "a",
        "stages": [
            {"id": "a", "name": "A", "step": {"type": "approval", "prompt": "ok?", "fields": [], "output_key": "decision"}}
        ],
        "edges": []
    }"#;

    #[test]
    fn parses_clean_json() {
        let wf = parse_workflow(MINIMAL).unwrap();
        assert_eq!(wf.id, "wf1");
        assert_eq!(wf.start, "a");
        assert_eq!(wf.stages.len(), 1);
    }

    #[test]
    fn parses_markdown_fenced() {
        let text = format!("Here is the workflow:\n\n```json\n{MINIMAL}\n```\n\nDone.");
        let wf = parse_workflow(&text).unwrap();
        assert_eq!(wf.id, "wf1");
    }

    #[test]
    fn parses_with_preamble() {
        let text = format!("Sure! {MINIMAL}");
        let wf = parse_workflow(&text).unwrap();
        assert_eq!(wf.id, "wf1");
    }

    #[test]
    fn garbage_fails() {
        assert!(parse_workflow("not json at all").is_err());
    }

    #[test]
    fn json_object_that_isnt_a_workflow_fails() {
        let err = parse_workflow(r#"{"hello": "world"}"#).unwrap_err();
        assert!(err.contains("not a valid workflow"));
    }
}