1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7#[derive(Default)]
8pub enum PlanStatus {
9 #[default]
11 Draft,
12 Active,
14 Paused,
16 Completed,
18 Abandoned,
20}
21
22impl std::fmt::Display for PlanStatus {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 match self {
25 PlanStatus::Draft => write!(f, "draft"),
26 PlanStatus::Active => write!(f, "active"),
27 PlanStatus::Paused => write!(f, "paused"),
28 PlanStatus::Completed => write!(f, "completed"),
29 PlanStatus::Abandoned => write!(f, "abandoned"),
30 }
31 }
32}
33
34impl std::str::FromStr for PlanStatus {
35 type Err = String;
36
37 fn from_str(s: &str) -> Result<Self, Self::Err> {
38 match s.to_lowercase().as_str() {
39 "draft" => Ok(PlanStatus::Draft),
40 "active" => Ok(PlanStatus::Active),
41 "paused" => Ok(PlanStatus::Paused),
42 "completed" => Ok(PlanStatus::Completed),
43 "abandoned" => Ok(PlanStatus::Abandoned),
44 _ => Err(format!("Unknown plan status: {}", s)),
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PlanMetadata {
52 pub plan_id: String,
54 pub conversation_id: String,
56 pub title: String,
58 pub task_description: String,
60 pub plan_content: String,
62 pub model_id: Option<String>,
64 pub status: PlanStatus,
66 pub executed: bool,
68 pub iterations_used: u32,
70 pub created_at: i64,
72 pub updated_at: i64,
74 pub file_path: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub embedding: Option<Vec<f32>>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub parent_plan_id: Option<String>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub child_plan_ids: Vec<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub branch_name: Option<String>,
88 #[serde(default)]
90 pub merged: bool,
91 #[serde(default)]
93 pub depth: u32,
94}
95
96impl PlanMetadata {
97 pub fn new(conversation_id: String, task_description: String, plan_content: String) -> Self {
99 let now = Utc::now().timestamp();
100 let plan_id = uuid::Uuid::new_v4().to_string();
101
102 let title = task_description
103 .lines()
104 .next()
105 .unwrap_or(&task_description)
106 .chars()
107 .take(50)
108 .collect::<String>();
109
110 Self {
111 plan_id,
112 conversation_id,
113 title,
114 task_description,
115 plan_content,
116 model_id: None,
117 status: PlanStatus::Draft,
118 executed: false,
119 iterations_used: 0,
120 created_at: now,
121 updated_at: now,
122 file_path: None,
123 embedding: None,
124 parent_plan_id: None,
125 child_plan_ids: Vec::new(),
126 branch_name: None,
127 merged: false,
128 depth: 0,
129 }
130 }
131
132 pub fn create_branch(
134 &self,
135 branch_name: String,
136 task_description: String,
137 plan_content: String,
138 ) -> Self {
139 let mut branch = Self::new(self.conversation_id.clone(), task_description, plan_content);
140 branch.parent_plan_id = Some(self.plan_id.clone());
141 branch.branch_name = Some(branch_name);
142 branch.depth = self.depth + 1;
143 branch
144 }
145
146 pub fn add_child(&mut self, child_id: String) {
148 if !self.child_plan_ids.contains(&child_id) {
149 self.child_plan_ids.push(child_id);
150 self.updated_at = Utc::now().timestamp();
151 }
152 }
153
154 pub fn mark_merged(&mut self) {
156 self.merged = true;
157 self.status = PlanStatus::Completed;
158 self.updated_at = Utc::now().timestamp();
159 }
160
161 pub fn is_root(&self) -> bool {
163 self.parent_plan_id.is_none()
164 }
165
166 pub fn has_children(&self) -> bool {
168 !self.child_plan_ids.is_empty()
169 }
170
171 pub fn with_model(mut self, model_id: String) -> Self {
173 self.model_id = Some(model_id);
174 self
175 }
176
177 pub fn with_iterations(mut self, iterations: u32) -> Self {
179 self.iterations_used = iterations;
180 self
181 }
182
183 pub fn mark_executed(&mut self) {
185 self.executed = true;
186 self.status = PlanStatus::Completed;
187 self.updated_at = Utc::now().timestamp();
188 }
189
190 pub fn set_status(&mut self, status: PlanStatus) {
192 self.status = status;
193 self.updated_at = Utc::now().timestamp();
194 }
195
196 pub fn set_file_path(&mut self, path: String) {
198 self.file_path = Some(path);
199 self.updated_at = Utc::now().timestamp();
200 }
201
202 pub fn created_at_datetime(&self) -> DateTime<Utc> {
204 DateTime::from_timestamp(self.created_at, 0).unwrap_or_else(Utc::now)
205 }
206
207 pub fn to_markdown(&self) -> String {
209 let created = self.created_at_datetime().format("%Y-%m-%dT%H:%M:%SZ");
210 let model = self.model_id.as_deref().unwrap_or("unknown");
211
212 format!(
213 r#"---
214plan_id: {}
215conversation_id: {}
216title: "{}"
217status: {}
218executed: {}
219iterations: {}
220created_at: {}
221model: {}
222---
223
224# Execution Plan: {}
225
226## Original Task
227
228{}
229
230## Plan
231
232{}
233
234---
235*Generated by Brainwires Agent Framework*
236"#,
237 self.plan_id,
238 self.conversation_id,
239 self.title.replace('"', r#"\""#),
240 self.status,
241 self.executed,
242 self.iterations_used,
243 created,
244 model,
245 self.title,
246 self.task_description,
247 self.plan_content
248 )
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct PlanStep {
255 pub step_number: u32,
257 pub description: String,
259 pub tool_hint: Option<String>,
261 pub estimated_tokens: u64,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct PlanBudget {
269 pub max_steps: Option<u32>,
271 pub max_estimated_tokens: Option<u64>,
273 pub max_estimated_cost_usd: Option<f64>,
275 pub cost_per_token: f64,
277}
278
279impl Default for PlanBudget {
280 fn default() -> Self {
281 Self {
282 max_steps: None,
283 max_estimated_tokens: None,
284 max_estimated_cost_usd: None,
285 cost_per_token: 0.000003,
286 }
287 }
288}
289
290impl PlanBudget {
291 pub fn new() -> Self {
293 Self::default()
294 }
295
296 pub fn with_max_steps(mut self, max: u32) -> Self {
298 self.max_steps = Some(max);
299 self
300 }
301
302 pub fn with_max_tokens(mut self, max: u64) -> Self {
304 self.max_estimated_tokens = Some(max);
305 self
306 }
307
308 pub fn with_max_cost_usd(mut self, max: f64) -> Self {
310 self.max_estimated_cost_usd = Some(max);
311 self
312 }
313
314 pub fn check(&self, plan: &SerializablePlan) -> Result<(), String> {
319 let step_count = plan.steps.len() as u32;
320 let total_tokens = plan.total_estimated_tokens();
321 let total_cost = total_tokens as f64 * self.cost_per_token;
322
323 if let Some(max) = self.max_steps
324 && step_count > max
325 {
326 return Err(format!(
327 "plan has {} steps but limit is {}",
328 step_count, max
329 ));
330 }
331
332 if let Some(max) = self.max_estimated_tokens
333 && total_tokens > max
334 {
335 return Err(format!(
336 "plan estimates {} tokens but limit is {}",
337 total_tokens, max
338 ));
339 }
340
341 if let Some(max) = self.max_estimated_cost_usd
342 && total_cost > max
343 {
344 return Err(format!(
345 "plan estimates ${:.6} USD but limit is ${:.6}",
346 total_cost, max
347 ));
348 }
349
350 Ok(())
351 }
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct SerializablePlan {
361 pub plan_id: String,
363 pub task_description: String,
365 pub steps: Vec<PlanStep>,
367 pub created_at: i64,
369}
370
371impl SerializablePlan {
372 pub fn new(task_description: String, steps: Vec<PlanStep>) -> Self {
374 Self {
375 plan_id: uuid::Uuid::new_v4().to_string(),
376 task_description,
377 steps,
378 created_at: chrono::Utc::now().timestamp(),
379 }
380 }
381
382 pub fn total_estimated_tokens(&self) -> u64 {
384 self.steps.iter().map(|s| s.estimated_tokens).sum()
385 }
386
387 pub fn step_count(&self) -> u32 {
389 self.steps.len() as u32
390 }
391
392 pub fn parse_from_text(task_description: String, text: &str) -> Option<Self> {
399 let start = text.find('{')?;
400 let end = text.rfind('}')?;
401 if start > end {
402 return None;
403 }
404 let json_str = &text[start..=end];
405 let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
406 let steps_array = value.get("steps")?.as_array()?;
407
408 let steps: Vec<PlanStep> = steps_array
409 .iter()
410 .enumerate()
411 .filter_map(|(i, step)| {
412 let description = step.get("description")?.as_str()?.to_string();
413 let estimated_tokens = step
414 .get("estimated_tokens")
415 .or_else(|| step.get("tokens"))
416 .and_then(|v| v.as_u64())
417 .unwrap_or(500);
418 let tool_hint = step
419 .get("tool")
420 .or_else(|| step.get("tool_hint"))
421 .and_then(|v| v.as_str())
422 .map(|s| s.to_string());
423 Some(PlanStep {
424 step_number: (i + 1) as u32,
425 description,
426 tool_hint,
427 estimated_tokens,
428 })
429 })
430 .collect();
431
432 if steps.is_empty() {
433 return None;
434 }
435
436 Some(Self::new(task_description, steps))
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn test_plan_metadata_new() {
446 let plan = PlanMetadata::new(
447 "conv-123".to_string(),
448 "Implement auth".to_string(),
449 "Step 1".to_string(),
450 );
451 assert!(!plan.plan_id.is_empty());
452 assert_eq!(plan.status, PlanStatus::Draft);
453 assert!(plan.is_root());
454 }
455
456 #[test]
457 fn test_plan_branching() {
458 let parent = PlanMetadata::new(
459 "conv-123".to_string(),
460 "Main".to_string(),
461 "Plan".to_string(),
462 );
463 let branch = parent.create_branch(
464 "feature-x".to_string(),
465 "Feature X".to_string(),
466 "Branch plan".to_string(),
467 );
468 assert_eq!(branch.parent_plan_id, Some(parent.plan_id));
469 assert_eq!(branch.depth, 1);
470 assert!(!branch.is_root());
471 }
472
473 #[test]
474 fn test_plan_budget_check_no_limits() {
475 let budget = PlanBudget::new();
476 let plan = SerializablePlan::new(
477 "task".into(),
478 vec![PlanStep {
479 step_number: 1,
480 description: "do thing".into(),
481 tool_hint: None,
482 estimated_tokens: 9_000_000,
483 }],
484 );
485 assert!(budget.check(&plan).is_ok());
487 }
488
489 #[test]
490 fn test_plan_budget_check_step_limit_exceeded() {
491 let budget = PlanBudget::new().with_max_steps(2);
492 let steps: Vec<PlanStep> = (1..=3)
493 .map(|i| PlanStep {
494 step_number: i,
495 description: format!("step {i}"),
496 tool_hint: None,
497 estimated_tokens: 100,
498 })
499 .collect();
500 let plan = SerializablePlan::new("task".into(), steps);
501 let result = budget.check(&plan);
502 assert!(result.is_err());
503 assert!(result.unwrap_err().contains("3 steps"));
504 }
505
506 #[test]
507 fn test_plan_budget_check_token_limit_exceeded() {
508 let budget = PlanBudget::new().with_max_tokens(500);
509 let steps: Vec<PlanStep> = (1..=3)
510 .map(|i| PlanStep {
511 step_number: i,
512 description: format!("step {i}"),
513 tool_hint: None,
514 estimated_tokens: 300,
515 })
516 .collect();
517 let plan = SerializablePlan::new("task".into(), steps);
518 let result = budget.check(&plan);
519 assert!(result.is_err());
520 assert!(result.unwrap_err().contains("900 tokens"));
521 }
522
523 #[test]
524 fn test_serializable_plan_parse_from_text() {
525 let text = r#"Here is my plan:
526{"steps":[{"description":"Read the file","tool":"read_file","estimated_tokens":300},{"description":"Write changes","tool":"write_file","estimated_tokens":500}]}
527That's the plan."#;
528 let plan = SerializablePlan::parse_from_text("task".into(), text).unwrap();
529 assert_eq!(plan.steps.len(), 2);
530 assert_eq!(plan.steps[0].step_number, 1);
531 assert_eq!(plan.steps[0].description, "Read the file");
532 assert_eq!(plan.steps[0].tool_hint, Some("read_file".to_string()));
533 assert_eq!(plan.total_estimated_tokens(), 800);
534 }
535
536 #[test]
537 fn test_serializable_plan_parse_empty_steps_returns_none() {
538 let text = r#"{"steps":[]}"#;
539 assert!(SerializablePlan::parse_from_text("task".into(), text).is_none());
540 }
541
542 #[test]
543 fn test_serializable_plan_parse_no_json_returns_none() {
544 assert!(SerializablePlan::parse_from_text("task".into(), "no json here").is_none());
545 }
546}