use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum PlanStatus {
#[default]
Draft,
Active,
Paused,
Completed,
Abandoned,
}
impl std::fmt::Display for PlanStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlanStatus::Draft => write!(f, "draft"),
PlanStatus::Active => write!(f, "active"),
PlanStatus::Paused => write!(f, "paused"),
PlanStatus::Completed => write!(f, "completed"),
PlanStatus::Abandoned => write!(f, "abandoned"),
}
}
}
impl std::str::FromStr for PlanStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"draft" => Ok(PlanStatus::Draft),
"active" => Ok(PlanStatus::Active),
"paused" => Ok(PlanStatus::Paused),
"completed" => Ok(PlanStatus::Completed),
"abandoned" => Ok(PlanStatus::Abandoned),
_ => Err(format!("Unknown plan status: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanMetadata {
pub plan_id: String,
pub conversation_id: String,
pub title: String,
pub task_description: String,
pub plan_content: String,
pub model_id: Option<String>,
pub status: PlanStatus,
pub executed: bool,
pub iterations_used: u32,
pub created_at: i64,
pub updated_at: i64,
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding: Option<Vec<f32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_plan_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub child_plan_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch_name: Option<String>,
#[serde(default)]
pub merged: bool,
#[serde(default)]
pub depth: u32,
}
impl PlanMetadata {
pub fn new(conversation_id: String, task_description: String, plan_content: String) -> Self {
let now = Utc::now().timestamp();
let plan_id = uuid::Uuid::new_v4().to_string();
let title = task_description
.lines()
.next()
.unwrap_or(&task_description)
.chars()
.take(50)
.collect::<String>();
Self {
plan_id,
conversation_id,
title,
task_description,
plan_content,
model_id: None,
status: PlanStatus::Draft,
executed: false,
iterations_used: 0,
created_at: now,
updated_at: now,
file_path: None,
embedding: None,
parent_plan_id: None,
child_plan_ids: Vec::new(),
branch_name: None,
merged: false,
depth: 0,
}
}
pub fn create_branch(
&self,
branch_name: String,
task_description: String,
plan_content: String,
) -> Self {
let mut branch = Self::new(self.conversation_id.clone(), task_description, plan_content);
branch.parent_plan_id = Some(self.plan_id.clone());
branch.branch_name = Some(branch_name);
branch.depth = self.depth + 1;
branch
}
pub fn add_child(&mut self, child_id: String) {
if !self.child_plan_ids.contains(&child_id) {
self.child_plan_ids.push(child_id);
self.updated_at = Utc::now().timestamp();
}
}
pub fn mark_merged(&mut self) {
self.merged = true;
self.status = PlanStatus::Completed;
self.updated_at = Utc::now().timestamp();
}
pub fn is_root(&self) -> bool {
self.parent_plan_id.is_none()
}
pub fn has_children(&self) -> bool {
!self.child_plan_ids.is_empty()
}
pub fn with_model(mut self, model_id: String) -> Self {
self.model_id = Some(model_id);
self
}
pub fn with_iterations(mut self, iterations: u32) -> Self {
self.iterations_used = iterations;
self
}
pub fn mark_executed(&mut self) {
self.executed = true;
self.status = PlanStatus::Completed;
self.updated_at = Utc::now().timestamp();
}
pub fn set_status(&mut self, status: PlanStatus) {
self.status = status;
self.updated_at = Utc::now().timestamp();
}
pub fn set_file_path(&mut self, path: String) {
self.file_path = Some(path);
self.updated_at = Utc::now().timestamp();
}
pub fn created_at_datetime(&self) -> DateTime<Utc> {
DateTime::from_timestamp(self.created_at, 0).unwrap_or_else(Utc::now)
}
pub fn to_markdown(&self) -> String {
let created = self.created_at_datetime().format("%Y-%m-%dT%H:%M:%SZ");
let model = self.model_id.as_deref().unwrap_or("unknown");
format!(
r#"---
plan_id: {}
conversation_id: {}
title: "{}"
status: {}
executed: {}
iterations: {}
created_at: {}
model: {}
---
# Execution Plan: {}
## Original Task
{}
## Plan
{}
---
*Generated by Brainwires Agent Framework*
"#,
self.plan_id,
self.conversation_id,
self.title.replace('"', r#"\""#),
self.status,
self.executed,
self.iterations_used,
created,
model,
self.title,
self.task_description,
self.plan_content
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStep {
pub step_number: u32,
pub description: String,
pub tool_hint: Option<String>,
pub estimated_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanBudget {
pub max_steps: Option<u32>,
pub max_estimated_tokens: Option<u64>,
pub max_estimated_cost_usd: Option<f64>,
pub cost_per_token: f64,
}
impl Default for PlanBudget {
fn default() -> Self {
Self {
max_steps: None,
max_estimated_tokens: None,
max_estimated_cost_usd: None,
cost_per_token: 0.000003,
}
}
}
impl PlanBudget {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_steps(mut self, max: u32) -> Self {
self.max_steps = Some(max);
self
}
pub fn with_max_tokens(mut self, max: u64) -> Self {
self.max_estimated_tokens = Some(max);
self
}
pub fn with_max_cost_usd(mut self, max: f64) -> Self {
self.max_estimated_cost_usd = Some(max);
self
}
pub fn check(&self, plan: &SerializablePlan) -> Result<(), String> {
let step_count = plan.steps.len() as u32;
let total_tokens = plan.total_estimated_tokens();
let total_cost = total_tokens as f64 * self.cost_per_token;
if let Some(max) = self.max_steps
&& step_count > max
{
return Err(format!(
"plan has {} steps but limit is {}",
step_count, max
));
}
if let Some(max) = self.max_estimated_tokens
&& total_tokens > max
{
return Err(format!(
"plan estimates {} tokens but limit is {}",
total_tokens, max
));
}
if let Some(max) = self.max_estimated_cost_usd
&& total_cost > max
{
return Err(format!(
"plan estimates ${:.6} USD but limit is ${:.6}",
total_cost, max
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializablePlan {
pub plan_id: String,
pub task_description: String,
pub steps: Vec<PlanStep>,
pub created_at: i64,
}
impl SerializablePlan {
pub fn new(task_description: String, steps: Vec<PlanStep>) -> Self {
Self {
plan_id: uuid::Uuid::new_v4().to_string(),
task_description,
steps,
created_at: chrono::Utc::now().timestamp(),
}
}
pub fn total_estimated_tokens(&self) -> u64 {
self.steps.iter().map(|s| s.estimated_tokens).sum()
}
pub fn step_count(&self) -> u32 {
self.steps.len() as u32
}
pub fn parse_from_text(task_description: String, text: &str) -> Option<Self> {
let start = text.find('{')?;
let end = text.rfind('}')?;
if start > end {
return None;
}
let json_str = &text[start..=end];
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
let steps_array = value.get("steps")?.as_array()?;
let steps: Vec<PlanStep> = steps_array
.iter()
.enumerate()
.filter_map(|(i, step)| {
let description = step.get("description")?.as_str()?.to_string();
let estimated_tokens = step
.get("estimated_tokens")
.or_else(|| step.get("tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(500);
let tool_hint = step
.get("tool")
.or_else(|| step.get("tool_hint"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(PlanStep {
step_number: (i + 1) as u32,
description,
tool_hint,
estimated_tokens,
})
})
.collect();
if steps.is_empty() {
return None;
}
Some(Self::new(task_description, steps))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plan_metadata_new() {
let plan = PlanMetadata::new(
"conv-123".to_string(),
"Implement auth".to_string(),
"Step 1".to_string(),
);
assert!(!plan.plan_id.is_empty());
assert_eq!(plan.status, PlanStatus::Draft);
assert!(plan.is_root());
}
#[test]
fn test_plan_branching() {
let parent = PlanMetadata::new(
"conv-123".to_string(),
"Main".to_string(),
"Plan".to_string(),
);
let branch = parent.create_branch(
"feature-x".to_string(),
"Feature X".to_string(),
"Branch plan".to_string(),
);
assert_eq!(branch.parent_plan_id, Some(parent.plan_id));
assert_eq!(branch.depth, 1);
assert!(!branch.is_root());
}
#[test]
fn test_plan_budget_check_no_limits() {
let budget = PlanBudget::new();
let plan = SerializablePlan::new(
"task".into(),
vec![PlanStep {
step_number: 1,
description: "do thing".into(),
tool_hint: None,
estimated_tokens: 9_000_000,
}],
);
assert!(budget.check(&plan).is_ok());
}
#[test]
fn test_plan_budget_check_step_limit_exceeded() {
let budget = PlanBudget::new().with_max_steps(2);
let steps: Vec<PlanStep> = (1..=3)
.map(|i| PlanStep {
step_number: i,
description: format!("step {i}"),
tool_hint: None,
estimated_tokens: 100,
})
.collect();
let plan = SerializablePlan::new("task".into(), steps);
let result = budget.check(&plan);
assert!(result.is_err());
assert!(result.unwrap_err().contains("3 steps"));
}
#[test]
fn test_plan_budget_check_token_limit_exceeded() {
let budget = PlanBudget::new().with_max_tokens(500);
let steps: Vec<PlanStep> = (1..=3)
.map(|i| PlanStep {
step_number: i,
description: format!("step {i}"),
tool_hint: None,
estimated_tokens: 300,
})
.collect();
let plan = SerializablePlan::new("task".into(), steps);
let result = budget.check(&plan);
assert!(result.is_err());
assert!(result.unwrap_err().contains("900 tokens"));
}
#[test]
fn test_serializable_plan_parse_from_text() {
let text = r#"Here is my plan:
{"steps":[{"description":"Read the file","tool":"read_file","estimated_tokens":300},{"description":"Write changes","tool":"write_file","estimated_tokens":500}]}
That's the plan."#;
let plan = SerializablePlan::parse_from_text("task".into(), text).unwrap();
assert_eq!(plan.steps.len(), 2);
assert_eq!(plan.steps[0].step_number, 1);
assert_eq!(plan.steps[0].description, "Read the file");
assert_eq!(plan.steps[0].tool_hint, Some("read_file".to_string()));
assert_eq!(plan.total_estimated_tokens(), 800);
}
#[test]
fn test_serializable_plan_parse_empty_steps_returns_none() {
let text = r#"{"steps":[]}"#;
assert!(SerializablePlan::parse_from_text("task".into(), text).is_none());
}
#[test]
fn test_serializable_plan_parse_no_json_returns_none() {
assert!(SerializablePlan::parse_from_text("task".into(), "no json here").is_none());
}
}