car-active-planner 0.8.0

Active planner for CAR — generates, scores, and selects proposals via inference
Documentation
//! ReplanCallback implementation using active planning.
//!
//! Composes candidate generation (inference) with static scoring (car-planner)
//! to produce the best alternative when a proposal fails.

use crate::generate::{self, ActivePlannerConfig};
use car_engine::{ReplanCallback, ReplanContext};
use car_inference::InferenceEngine;
use car_ir::ActionProposal;
use car_planner::{Planner, PlannerConfig, ToolFeedback};
use std::collections::HashSet;
use std::sync::Arc;

/// A ReplanCallback that generates diverse candidates via inference,
/// scores them with the static planner, and returns the best one.
pub struct ActiveReplanAdapter {
    engine: Arc<InferenceEngine>,
    planner: Planner,
    config: ActivePlannerConfig,
    feedback: Option<ToolFeedback>,
}

impl ActiveReplanAdapter {
    pub fn new(
        engine: Arc<InferenceEngine>,
        config: ActivePlannerConfig,
        planner_config: PlannerConfig,
    ) -> Self {
        Self {
            engine,
            planner: Planner::new(planner_config),
            config,
            feedback: None,
        }
    }

    /// Attach historical tool feedback for scoring.
    pub fn with_feedback(mut self, feedback: ToolFeedback) -> Self {
        self.feedback = Some(feedback);
        self
    }
}

#[async_trait::async_trait]
impl ReplanCallback for ActiveReplanAdapter {
    async fn replan(&self, ctx: &ReplanContext) -> Result<ActionProposal, String> {
        // Build failure context string from the ReplanContext
        let failure_desc = ctx.failed_actions.iter()
            .map(|f| {
                let tool = f.tool.as_deref().unwrap_or("unknown");
                format!("Action '{}' (tool: {}) failed: {}", f.action_id, tool, f.error)
            })
            .collect::<Vec<_>>()
            .join("\n");

        let failure_context = format!(
            "{}\nAttempt {} of {}. State after rollback has {} keys.",
            failure_desc,
            ctx.attempt,
            ctx.attempt + ctx.replans_remaining,
            ctx.state_snapshot.len(),
        );

        // Adapt candidate count to remaining budget
        let mut config = self.config.clone();
        if ctx.replans_remaining == 0 {
            // Last chance — use only conservative strategy with low temperature
            config.strategies = vec![generate::Strategy::Conservative];
        }

        // Build goal from original context (carries the actual objective)
        let goal = if let Some(goal_val) = ctx.original_context.get("goal") {
            goal_val.as_str().unwrap_or("").to_string()
        } else if let Some(desc) = ctx.original_context.get("description") {
            desc.as_str().unwrap_or("").to_string()
        } else {
            // Fallback: construct from metadata
            format!(
                "Replan for '{}' (originally {} actions). Accomplish the same objective with a different approach.",
                ctx.original_source, ctx.original_action_count,
            )
        };
        let candidates = generate::generate_candidates(
            &self.engine, &goal, &config, Some(&failure_context),
        ).await;

        if candidates.is_empty() {
            return Err("all candidate generation attempts failed to produce valid proposals".into());
        }

        // Score and pick best
        let tool_names: HashSet<String> = self.config.available_tools.clone();
        let state = ctx.state_snapshot.clone();
        let ranked = self.planner.rank_with_feedback(
            &candidates,
            Some(&state),
            Some(&tool_names),
            self.feedback.as_ref(),
        );

        ranked.into_iter()
            .find(|s| s.valid)
            .map(|s| candidates[s.index].clone())
            .ok_or_else(|| "all generated candidates failed verification".into())
    }
}

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

    #[test]
    fn adapter_constructible() {
        // Verify the adapter can be constructed (no runtime test — needs inference engine)
        let config = ActivePlannerConfig::default();
        let planner_config = PlannerConfig::default();
        // Can't construct InferenceEngine in unit test, but types check out
        assert_eq!(config.strategies.len(), 3);
        assert_eq!(planner_config.cost_weight, 0.2);
    }
}