Skip to main content

brainwires_core/
plan.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Status of a plan
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7#[derive(Default)]
8pub enum PlanStatus {
9    /// Plan is in draft state (not yet started).
10    #[default]
11    Draft,
12    /// Plan is actively being executed.
13    Active,
14    /// Plan execution is paused.
15    Paused,
16    /// Plan has been completed successfully.
17    Completed,
18    /// Plan has been abandoned.
19    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/// Metadata for a persisted execution plan
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PlanMetadata {
52    /// Unique plan identifier.
53    pub plan_id: String,
54    /// Conversation this plan belongs to.
55    pub conversation_id: String,
56    /// Short title derived from the task description.
57    pub title: String,
58    /// Full task description the plan was created for.
59    pub task_description: String,
60    /// The plan content (steps, instructions).
61    pub plan_content: String,
62    /// Model used to generate the plan, if known.
63    pub model_id: Option<String>,
64    /// Current status of the plan.
65    pub status: PlanStatus,
66    /// Whether the plan has been executed.
67    pub executed: bool,
68    /// Number of iterations used during execution.
69    pub iterations_used: u32,
70    /// Unix timestamp when the plan was created.
71    pub created_at: i64,
72    /// Unix timestamp when the plan was last updated.
73    pub updated_at: i64,
74    /// File path if the plan was exported to disk.
75    pub file_path: Option<String>,
76    /// Optional embedding vector for similarity search.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub embedding: Option<Vec<f32>>,
79    /// Parent plan ID for branched plans.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub parent_plan_id: Option<String>,
82    /// IDs of child (branched) plans.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub child_plan_ids: Vec<String>,
85    /// Branch name for branched plans.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub branch_name: Option<String>,
88    /// Whether this branch has been merged back.
89    #[serde(default)]
90    pub merged: bool,
91    /// Nesting depth in the plan tree.
92    #[serde(default)]
93    pub depth: u32,
94}
95
96impl PlanMetadata {
97    /// Create a new plan with the given task and content
98    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    /// Create a branch (sub-plan) from this plan
133    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    /// Add a child plan ID
147    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    /// Mark as merged
155    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    /// Check if this is a root plan
162    pub fn is_root(&self) -> bool {
163        self.parent_plan_id.is_none()
164    }
165
166    /// Check if this plan has children
167    pub fn has_children(&self) -> bool {
168        !self.child_plan_ids.is_empty()
169    }
170
171    /// Set the model used
172    pub fn with_model(mut self, model_id: String) -> Self {
173        self.model_id = Some(model_id);
174        self
175    }
176
177    /// Set iterations used
178    pub fn with_iterations(mut self, iterations: u32) -> Self {
179        self.iterations_used = iterations;
180        self
181    }
182
183    /// Mark as executed
184    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    /// Update status
191    pub fn set_status(&mut self, status: PlanStatus) {
192        self.status = status;
193        self.updated_at = Utc::now().timestamp();
194    }
195
196    /// Set file path after export
197    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    /// Get created_at as DateTime
203    pub fn created_at_datetime(&self) -> DateTime<Utc> {
204        DateTime::from_timestamp(self.created_at, 0).unwrap_or_else(Utc::now)
205    }
206
207    /// Generate markdown export with YAML frontmatter
208    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/// A single step in a serializable pre-execution plan.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct PlanStep {
255    /// Sequential step index, 1-based.
256    pub step_number: u32,
257    /// Short human-readable description of what this step will do.
258    pub description: String,
259    /// Name of the tool this step is expected to invoke, if known.
260    pub tool_hint: Option<String>,
261    /// Estimated tokens this step will consume (prompt + completion combined).
262    pub estimated_tokens: u64,
263}
264
265/// Budget constraints that a serializable plan must satisfy before execution
266/// begins. Used in `TaskAgentConfig::plan_budget`.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct PlanBudget {
269    /// Reject plans with more steps than this limit.
270    pub max_steps: Option<u32>,
271    /// Reject plans whose total estimated tokens exceed this ceiling.
272    pub max_estimated_tokens: Option<u64>,
273    /// Reject plans whose estimated cost (USD) exceeds this ceiling.
274    pub max_estimated_cost_usd: Option<f64>,
275    /// Cost per token used for the USD estimate. Default: 0.000003 ($3/M).
276    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    /// Create a new budget with no limits (accepts any plan).
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    /// Set the maximum allowed step count.
297    pub fn with_max_steps(mut self, max: u32) -> Self {
298        self.max_steps = Some(max);
299        self
300    }
301
302    /// Set the maximum allowed estimated token count.
303    pub fn with_max_tokens(mut self, max: u64) -> Self {
304        self.max_estimated_tokens = Some(max);
305        self
306    }
307
308    /// Set the maximum allowed estimated cost in USD.
309    pub fn with_max_cost_usd(mut self, max: f64) -> Self {
310        self.max_estimated_cost_usd = Some(max);
311        self
312    }
313
314    /// Check a plan against this budget.
315    ///
316    /// Returns `Ok(())` when the plan is within budget, or `Err(reason)` with
317    /// a human-readable explanation when any limit is exceeded.
318    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/// A serializable execution plan produced by the agent *before* any side
355/// effects occur.  When `TaskAgentConfig::plan_budget` is set, the agent
356/// generates this plan in a separate provider call and validates it against the
357/// budget; if the budget is exceeded the run fails immediately with
358/// `FailureCategory::PlanBudgetExceeded`.
359#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct SerializablePlan {
361    /// Unique identifier for this plan.
362    pub plan_id: String,
363    /// The original task description the plan was built for.
364    pub task_description: String,
365    /// Ordered steps in the plan.
366    pub steps: Vec<PlanStep>,
367    /// Unix timestamp (seconds) when the plan was generated.
368    pub created_at: i64,
369}
370
371impl SerializablePlan {
372    /// Create a new plan with a generated ID and the current timestamp.
373    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    /// Sum of all `estimated_tokens` across steps.
383    pub fn total_estimated_tokens(&self) -> u64 {
384        self.steps.iter().map(|s| s.estimated_tokens).sum()
385    }
386
387    /// Number of steps in this plan.
388    pub fn step_count(&self) -> u32 {
389        self.steps.len() as u32
390    }
391
392    /// Parse a plan from model-generated text that contains an embedded JSON
393    /// object with a `"steps"` array.
394    ///
395    /// Finds the first `{` … last `}` span in `text`, parses the JSON, and
396    /// extracts the steps array.  Returns `None` when no valid plan JSON is
397    /// found or the steps array is empty.
398    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        // No limits set — always passes
486        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}