use crate::parse;
use car_inference::{GenerateParams, GenerateRequest, InferenceEngine};
use car_ir::ActionProposal;
use std::collections::HashSet;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum Strategy {
Conservative,
Aggressive,
Minimal,
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,
}
}
}
#[derive(Debug, Clone)]
pub struct ActivePlannerConfig {
pub strategies: Vec<Strategy>,
pub max_tokens: usize,
pub model: Option<String>,
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(),
}
}
}
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(),
)
}
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());
}
}