car-active-planner 0.13.0

Active planner for CAR — generates, scores, and selects proposals via inference
Documentation
//! Candidate generation — produces diverse ActionProposals via inference.

use crate::parse;
use car_inference::{GenerateParams, GenerateRequest, InferenceEngine};
use car_ir::ActionProposal;
use std::collections::HashSet;
use std::sync::Arc;

/// Strategy prompts that bias the model toward different plan structures.
#[derive(Debug, Clone)]
pub enum Strategy {
    /// Fewest actions, prefer idempotent tools, minimize state writes.
    Conservative,
    /// Parallelize aggressively, use capable tools, optimize for speed.
    Aggressive,
    /// Simplest single-action plan that could work.
    Minimal,
    /// Custom strategy with a free-form instruction.
    Custom(String),
}

impl Strategy {
    fn instruction(&self) -> &str {
        match self {
            Strategy::Conservative => "Use the fewest actions possible. Prefer idempotent operations. Minimize state writes. Prioritize safety over speed.",
            Strategy::Aggressive => "Parallelize aggressively where actions are independent. Use the most capable tools available. Optimize for speed over caution.",
            Strategy::Minimal => "What is the simplest single-action plan that could solve this? Avoid multi-step plans unless absolutely necessary.",
            Strategy::Custom(s) => s.as_str(),
        }
    }

    fn temperature(&self) -> f64 {
        match self {
            Strategy::Conservative => 0.3,
            Strategy::Aggressive => 0.5,
            Strategy::Minimal => 0.2,
            Strategy::Custom(_) => 0.4,
        }
    }
}

/// Configuration for active planning.
#[derive(Debug, Clone)]
pub struct ActivePlannerConfig {
    /// Strategies to use for diverse candidate generation.
    /// Each strategy produces one candidate. Default: [Conservative, Aggressive, Minimal].
    pub strategies: Vec<Strategy>,
    /// Maximum tokens per generation call.
    pub max_tokens: usize,
    /// Optional model override (uses default routing if None).
    pub model: Option<String>,
    /// Registered tool names — included in the prompt so the model knows what's available.
    pub available_tools: HashSet<String>,
}

impl Default for ActivePlannerConfig {
    fn default() -> Self {
        Self {
            strategies: vec![
                Strategy::Conservative,
                Strategy::Aggressive,
                Strategy::Minimal,
            ],
            max_tokens: 2048,
            model: None,
            available_tools: HashSet::new(),
        }
    }
}

/// Build the prompt for generating an ActionProposal.
fn build_prompt(
    goal: &str,
    strategy: &Strategy,
    tools: &HashSet<String>,
    failure_context: Option<&str>,
) -> String {
    let tools_list = if tools.is_empty() {
        "No specific tools registered.".to_string()
    } else {
        let mut sorted: Vec<&String> = tools.iter().collect();
        sorted.sort();
        format!(
            "Available tools: {}",
            sorted
                .iter()
                .map(|s| s.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        )
    };

    let failure_section = failure_context
        .map(|ctx| format!("\n\nPrevious attempt failed:\n{ctx}\nGenerate a DIFFERENT approach that avoids the same failure."))
        .unwrap_or_default();

    format!(
        r#"Generate an ActionProposal as JSON to accomplish this goal:

{goal}
{failure_section}

{tools_list}

Strategy: {strategy}

Respond with a single JSON object matching this schema:
{{
  "id": "unique-id",
  "source": "active-planner",
  "actions": [
    {{
      "id": "action-id",
      "type": "tool_call",  // or "state_write" or "state_read"
      "tool": "tool_name (for tool_call only)",
      "parameters": {{}},
      "preconditions": [],
      "expected_effects": {{}},
      "state_dependencies": [],
      "idempotent": false,
      "max_retries": 3,
      "failure_behavior": "abort",  // or "retry" or "skip"
      "timeout_ms": null,
      "metadata": {{}}
    }}
  ],
  "context": {{}}
}}

Respond with ONLY the JSON object, no markdown or explanation."#,
        strategy = strategy.instruction(),
    )
}

/// Generate N diverse candidate proposals in parallel.
///
/// Each strategy produces one candidate via a separate inference call with
/// its own temperature and instruction bias. Candidates that fail to parse
/// are silently dropped — the caller should check the returned count.
pub async fn generate_candidates(
    engine: &Arc<InferenceEngine>,
    goal: &str,
    config: &ActivePlannerConfig,
    failure_context: Option<&str>,
) -> Vec<ActionProposal> {
    let futs: Vec<_> = config
        .strategies
        .iter()
        .map(|strategy| {
            let engine = engine.clone();
            let prompt = build_prompt(goal, strategy, &config.available_tools, failure_context);
            let model = config.model.clone();
            let max_tokens = config.max_tokens;
            let temperature = strategy.temperature();

            async move {
                let req = GenerateRequest {
                    prompt,
                    model,
                    params: GenerateParams {
                        temperature,
                        max_tokens,
                        ..Default::default()
                    },
                    context: None,
                    tools: None,
                    images: None,
                    messages: None,
                    cache_control: false,
                    response_format: None,
                    intent: None,
                };

                match engine.generate(req).await {
                    Ok(text) => parse::parse_proposal(&text).ok(),
                    Err(e) => {
                        tracing::warn!("candidate generation failed: {}", e);
                        None
                    }
                }
            }
        })
        .collect();

    let results = futures::future::join_all(futs).await;
    results.into_iter().flatten().collect()
}

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

    #[test]
    fn prompt_includes_goal_and_tools() {
        let tools: HashSet<String> = ["search".into(), "write".into()].into();
        let prompt = build_prompt("fix the bug", &Strategy::Conservative, &tools, None);
        assert!(prompt.contains("fix the bug"));
        assert!(prompt.contains("search"));
        assert!(prompt.contains("write"));
        assert!(prompt.contains("fewest actions"));
    }

    #[test]
    fn prompt_includes_failure_context() {
        let prompt = build_prompt(
            "deploy",
            &Strategy::Aggressive,
            &HashSet::new(),
            Some("tool 'deploy' returned 500 error"),
        );
        assert!(prompt.contains("Previous attempt failed"));
        assert!(prompt.contains("500 error"));
        assert!(prompt.contains("DIFFERENT approach"));
    }

    #[test]
    fn strategies_have_different_temperatures() {
        assert!(Strategy::Minimal.temperature() < Strategy::Aggressive.temperature());
    }
}