car-active-planner 0.14.0

Active planner for CAR — generates, scores, and selects proposals via inference
Documentation
//! LLM output → ActionProposal parser with JSON repair.

use car_ir::json_extract::{extract_json_object as extract_json_block, repair_json};
use car_ir::ActionProposal;

/// Error from parsing LLM output into an ActionProposal.
#[derive(Debug, Clone)]
pub struct ParseError {
    pub message: String,
    pub raw_text: String,
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

/// Parse LLM text output into an ActionProposal.
///
/// Tries multiple strategies:
/// 1. Direct JSON parse of the entire response
/// 2. Extract first `{...}` block (handles markdown fences, preamble text)
/// 3. JSON repair (trailing commas, unquoted keys)
pub fn parse_proposal(text: &str) -> Result<ActionProposal, ParseError> {
    let trimmed = text.trim();

    // Strategy 1: direct parse
    if let Ok(proposal) = serde_json::from_str::<ActionProposal>(trimmed) {
        return Ok(proposal);
    }

    // Strategy 2: extract JSON block from markdown or surrounding text
    if let Some(json_str) = extract_json_block(trimmed) {
        if let Ok(proposal) = serde_json::from_str::<ActionProposal>(&json_str) {
            return Ok(proposal);
        }
        // Strategy 3: repair common JSON issues
        let repaired = repair_json(&json_str);
        if let Ok(proposal) = serde_json::from_str::<ActionProposal>(&repaired) {
            return Ok(proposal);
        }
    }

    Err(ParseError {
        message: format!(
            "failed to parse ActionProposal from LLM output ({} chars)",
            text.len()
        ),
        raw_text: text.to_string(),
    })
}

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

    #[test]
    fn parse_clean_json() {
        let json = r#"{"id":"p1","source":"test","actions":[{"id":"a1","type":"tool_call","tool":"search","parameters":{"q":"rust"},"preconditions":[],"expected_effects":{},"state_dependencies":[],"idempotent":false,"max_retries":3,"failure_behavior":"abort","timeout_ms":null,"metadata":{}}],"context":{}}"#;
        let result = parse_proposal(json);
        assert!(result.is_ok());
        let p = result.unwrap();
        assert_eq!(p.id, "p1");
        assert_eq!(p.actions.len(), 1);
    }

    #[test]
    fn parse_markdown_fenced() {
        let text = r#"Here is the plan:

```json
{"id":"p2","source":"test","actions":[],"context":{}}
```

This should work."#;
        let result = parse_proposal(text);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().id, "p2");
    }

    #[test]
    fn parse_with_preamble() {
        let text = r#"Sure, here's your plan:
{"id":"p3","source":"test","actions":[],"context":{}}
Let me know if you need changes."#;
        let result = parse_proposal(text);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().id, "p3");
    }

    #[test]
    fn parse_trailing_comma_repair() {
        let text = r#"{"id":"p4","source":"test","actions":[],"context":{},}"#;
        let result = parse_proposal(text);
        assert!(result.is_ok());
    }

    #[test]
    fn parse_garbage_fails() {
        let result = parse_proposal("not json at all");
        assert!(result.is_err());
        assert!(result.unwrap_err().message.contains("failed to parse"));
    }

    #[test]
    fn extract_nested_json() {
        let text = r#"blah {"id":"x","inner":{"a":1}} blah"#;
        let block = extract_json_block(text).unwrap();
        assert_eq!(block, r#"{"id":"x","inner":{"a":1}}"#);
    }
}