use std::time::{Duration, Instant};
use super::agent_classifier::AgentRoute;
pub const MAX_STEPS: usize = 5;
pub const MAX_WALL: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanStepKind {
Architecture,
ConventionLookup,
ImpactedFiles,
ExampleSites,
Synthesize,
}
impl std::fmt::Display for PlanStepKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Architecture => "architecture",
Self::ConventionLookup => "convention_lookup",
Self::ImpactedFiles => "impacted_files",
Self::ExampleSites => "example_sites",
Self::Synthesize => "synthesize",
};
f.write_str(s)
}
}
#[derive(Debug, Clone)]
pub struct PlanStep {
pub kind: PlanStepKind,
pub query: String,
}
#[derive(Debug, Clone)]
pub struct Plan {
pub steps: Vec<PlanStep>,
pub intent: AgentRoute,
}
#[derive(Debug, Clone)]
pub struct StepOutcome {
pub kind: PlanStepKind,
pub query: String,
pub output: String,
}
#[derive(Debug, Clone)]
pub struct PlannerResult {
pub merged: String,
pub per_step: Vec<StepOutcome>,
pub elapsed: Duration,
}
#[derive(Debug)]
pub enum PlanError {
NoSteps,
TooManySteps(usize),
Timeout(Duration),
StepFailed {
idx: usize,
kind: PlanStepKind,
msg: String,
},
}
impl std::fmt::Display for PlanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoSteps => f.write_str("plan must contain at least one step"),
Self::TooManySteps(n) => {
write!(f, "plan has {n} steps, exceeds MAX_STEPS={MAX_STEPS}")
}
Self::Timeout(d) => write!(f, "planner timed out after {d:?} (cap {MAX_WALL:?})"),
Self::StepFailed { idx, kind, msg } => {
write!(f, "step {idx} ({kind}) failed: {msg}")
}
}
}
}
impl std::error::Error for PlanError {}
impl Plan {
pub fn new(query: &str, intent: AgentRoute) -> Result<Self, PlanError> {
let query = query.trim();
let steps = match intent {
AgentRoute::FeaturePlanning => vec![
PlanStep {
kind: PlanStepKind::Architecture,
query: format!("architecture overview relevant to: {query}"),
},
PlanStep {
kind: PlanStepKind::ConventionLookup,
query: format!("existing conventions for: {query}"),
},
PlanStep {
kind: PlanStepKind::ImpactedFiles,
query: format!("files that would change when implementing: {query}"),
},
],
_ => vec![
PlanStep {
kind: PlanStepKind::ConventionLookup,
query: query.to_string(),
},
PlanStep {
kind: PlanStepKind::Synthesize,
query: query.to_string(),
},
],
};
Self::from_steps(steps, intent)
}
pub fn from_steps(steps: Vec<PlanStep>, intent: AgentRoute) -> Result<Self, PlanError> {
if steps.is_empty() {
return Err(PlanError::NoSteps);
}
if steps.len() > MAX_STEPS {
return Err(PlanError::TooManySteps(steps.len()));
}
Ok(Self { steps, intent })
}
pub fn execute<F>(&self, runner: F) -> Result<PlannerResult, PlanError>
where
F: FnMut(&PlanStep, Duration) -> anyhow::Result<String>,
{
let deadline = Instant::now() + MAX_WALL;
self.execute_with_deadline(deadline, runner)
}
pub(crate) fn execute_with_deadline<F>(
&self,
deadline: Instant,
mut runner: F,
) -> Result<PlannerResult, PlanError>
where
F: FnMut(&PlanStep, Duration) -> anyhow::Result<String>,
{
if self.steps.is_empty() {
return Err(PlanError::NoSteps);
}
if self.steps.len() > MAX_STEPS {
return Err(PlanError::TooManySteps(self.steps.len()));
}
let start = Instant::now();
let mut per_step: Vec<StepOutcome> = Vec::with_capacity(self.steps.len());
for (idx, step) in self.steps.iter().enumerate() {
let now = Instant::now();
if now >= deadline {
return Err(PlanError::Timeout(now.duration_since(start)));
}
let remaining = deadline.saturating_duration_since(now);
match runner(step, remaining) {
Ok(output) => per_step.push(StepOutcome {
kind: step.kind,
query: step.query.clone(),
output,
}),
Err(e) => {
return Err(PlanError::StepFailed {
idx,
kind: step.kind,
msg: e.to_string(),
});
}
}
if Instant::now() >= deadline {
return Err(PlanError::Timeout(start.elapsed()));
}
}
Ok(PlannerResult {
merged: merge_outcomes(&per_step),
per_step,
elapsed: start.elapsed(),
})
}
}
fn merge_outcomes(outcomes: &[StepOutcome]) -> String {
let mut out = String::with_capacity(1024);
out.push_str("# Multi-step plan results\n\n");
for (i, o) in outcomes.iter().enumerate() {
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!("## Step {}: {} — `{}`\n\n", i + 1, o.kind, o.query),
);
if o.output.is_empty() {
out.push_str("_(no results)_\n\n");
} else {
out.push_str(&o.output);
if !o.output.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn plan_new_feature_planning_has_three_steps() {
let p = Plan::new("if I wanted to add logging", AgentRoute::FeaturePlanning).unwrap();
assert_eq!(p.steps.len(), 3);
assert_eq!(p.steps[0].kind, PlanStepKind::Architecture);
assert_eq!(p.steps[1].kind, PlanStepKind::ConventionLookup);
assert_eq!(p.steps[2].kind, PlanStepKind::ImpactedFiles);
assert!(matches!(p.intent, AgentRoute::FeaturePlanning));
}
#[test]
fn plan_new_non_feature_planning_is_compact() {
let p = Plan::new("explain something", AgentRoute::Deep).unwrap();
assert!(p.steps.len() <= MAX_STEPS);
assert!(!p.steps.is_empty());
}
#[test]
fn from_steps_rejects_empty() {
let err = Plan::from_steps(vec![], AgentRoute::FeaturePlanning).unwrap_err();
assert!(matches!(err, PlanError::NoSteps));
}
#[test]
fn from_steps_rejects_more_than_max() {
let mut steps = Vec::new();
for _ in 0..=MAX_STEPS {
steps.push(PlanStep {
kind: PlanStepKind::ConventionLookup,
query: "x".into(),
});
}
let err = Plan::from_steps(steps, AgentRoute::FeaturePlanning).unwrap_err();
match err {
PlanError::TooManySteps(n) => assert_eq!(n, MAX_STEPS + 1),
other => panic!("expected TooManySteps, got {other:?}"),
}
}
#[test]
fn execute_runs_every_step_and_merges() {
let plan = Plan::new("if I wanted to add caching", AgentRoute::FeaturePlanning).unwrap();
let calls = std::cell::RefCell::new(0usize);
let result = plan
.execute(|step, _remaining| {
*calls.borrow_mut() += 1;
Ok(format!("result for {}: {}", step.kind, step.query))
})
.unwrap();
assert_eq!(*calls.borrow(), 3);
assert_eq!(result.per_step.len(), 3);
assert!(result.merged.contains("Step 1"));
assert!(result.merged.contains("Step 2"));
assert!(result.merged.contains("Step 3"));
assert!(result.merged.contains("architecture"));
assert!(result.merged.contains("convention_lookup"));
assert!(result.merged.contains("impacted_files"));
}
#[test]
fn execute_step_failure_propagates() {
let plan = Plan::new("anything", AgentRoute::FeaturePlanning).unwrap();
let err = plan
.execute(|_step, _remaining| Err(anyhow::anyhow!("boom")))
.unwrap_err();
match err {
PlanError::StepFailed { idx, msg, .. } => {
assert_eq!(idx, 0);
assert!(msg.contains("boom"));
}
other => panic!("expected StepFailed, got {other:?}"),
}
}
#[test]
fn execute_times_out_when_runner_sleeps_past_deadline() {
let plan = Plan::new("test", AgentRoute::FeaturePlanning).unwrap();
let deadline = Instant::now() + Duration::from_millis(20);
let err = execute_with_deadline_for_test(&plan, deadline, |_step, _remaining| {
thread::sleep(Duration::from_millis(40));
Ok(String::from("done"))
})
.unwrap_err();
assert!(
matches!(err, PlanError::Timeout(_)),
"expected Timeout, got {err:?}"
);
}
#[test]
fn execute_succeeds_within_deadline() {
let plan = Plan::new("test", AgentRoute::FeaturePlanning).unwrap();
let deadline = Instant::now() + Duration::from_secs(1);
let result = execute_with_deadline_for_test(&plan, deadline, |_step, _remaining| {
Ok(String::from("done"))
})
.unwrap();
assert_eq!(result.per_step.len(), 3);
}
fn execute_with_deadline_for_test<F>(
plan: &Plan,
deadline: Instant,
runner: F,
) -> Result<PlannerResult, PlanError>
where
F: FnMut(&PlanStep, Duration) -> anyhow::Result<String>,
{
plan.execute_with_deadline(deadline, runner)
}
}