Skip to main content

opendev_tools_impl/
present_plan.rs

1//! Present plan tool — present a plan file for user approval.
2//!
3//! After a Planner subagent writes a plan to a file, the main agent calls
4//! this tool to present the plan and get user sign-off before implementation.
5//!
6//! On approval the tool:
7//! 1. Registers the plan in the `PlanIndex` for session tracking
8//! 2. Stores the plan file to `~/.opendev/plans/`
9//!
10//! Todo creation is deferred to the LLM's `write_todos` call after approval,
11//! which produces better hierarchical grouping than automated parsing.
12
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::{Arc, Mutex};
16
17use opendev_runtime::{PlanApprovalRequest, PlanApprovalSender, PlanIndex, TodoManager};
18use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
19
20/// Minimum plan length in characters to be considered valid.
21const MIN_PLAN_LENGTH: usize = 100;
22
23/// Tool for presenting a completed plan to the user for approval.
24#[derive(Debug)]
25pub struct PresentPlanTool {
26    /// Shared todo manager — steps are created here on approval.
27    todo_manager: Option<Arc<Mutex<TodoManager>>>,
28    /// Channel to send plan approval requests to the TUI.
29    /// When `Some`, the tool blocks until the user approves/rejects.
30    /// When `None` (headless/non-interactive), auto-approve.
31    approval_tx: Option<PlanApprovalSender>,
32}
33
34impl PresentPlanTool {
35    /// Create a present_plan tool without todo integration (auto-approve only).
36    pub fn new() -> Self {
37        Self {
38            todo_manager: None,
39            approval_tx: None,
40        }
41    }
42
43    /// Create a present_plan tool with a shared todo manager.
44    ///
45    /// When a plan is approved, its implementation steps will be added
46    /// as todos to this manager.
47    pub fn with_todo_manager(manager: Arc<Mutex<TodoManager>>) -> Self {
48        Self {
49            todo_manager: Some(manager),
50            approval_tx: None,
51        }
52    }
53
54    /// Attach a plan approval channel for interactive (TUI) mode.
55    ///
56    /// When set, `execute()` sends the plan content through this channel
57    /// and blocks until the user approves, rejects, or requests modification.
58    pub fn with_approval_tx(mut self, tx: PlanApprovalSender) -> Self {
59        self.approval_tx = Some(tx);
60        self
61    }
62
63    /// Plans directory: `~/.opendev/plans/`.
64    fn plans_dir() -> Option<PathBuf> {
65        dirs::home_dir().map(|h| h.join(".opendev").join("plans"))
66    }
67}
68
69impl Default for PresentPlanTool {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75#[async_trait::async_trait]
76impl BaseTool for PresentPlanTool {
77    fn name(&self) -> &str {
78        "present_plan"
79    }
80
81    fn description(&self) -> &str {
82        "Present a completed plan file to the user for approval. \
83         The plan must have ---BEGIN PLAN--- / ---END PLAN--- delimiters, \
84         implementation steps, and a verification section."
85    }
86
87    fn parameter_schema(&self) -> serde_json::Value {
88        serde_json::json!({
89            "type": "object",
90            "properties": {
91                "plan_file_path": {
92                    "type": "string",
93                    "description": "Absolute path to the plan file"
94                }
95            },
96            "required": ["plan_file_path"]
97        })
98    }
99
100    async fn execute(
101        &self,
102        args: HashMap<String, serde_json::Value>,
103        ctx: &ToolContext,
104    ) -> ToolResult {
105        let plan_file_path = match args.get("plan_file_path").and_then(|v| v.as_str()) {
106            Some(p) if !p.is_empty() => p,
107            _ => return ToolResult::fail("plan_file_path is required"),
108        };
109
110        // Expand ~ and resolve path
111        let plan_path = expand_tilde(plan_file_path);
112
113        if !plan_path.exists() {
114            return ToolResult {
115                success: false,
116                output: Some(
117                    "Plan file does not exist. Spawn a Planner subagent \
118                     to create the plan first."
119                        .to_string(),
120                ),
121                error: Some(format!("Plan file not found: {plan_file_path}")),
122                metadata: HashMap::new(),
123                duration_ms: None,
124                llm_suffix: None,
125            };
126        }
127
128        // Read plan content
129        let plan_content = match std::fs::read_to_string(&plan_path) {
130            Ok(c) => c,
131            Err(e) => return ToolResult::fail(format!("Failed to read plan file: {e}")),
132        };
133
134        // Validate plan is not empty
135        let stripped = plan_content.trim();
136        if stripped.is_empty() {
137            return ToolResult {
138                success: false,
139                output: Some(
140                    "Plan file exists but is empty. Spawn a Planner subagent \
141                     to write the plan first."
142                        .to_string(),
143                ),
144                error: Some(format!("Plan file is empty: {plan_file_path}")),
145                metadata: HashMap::new(),
146                duration_ms: None,
147                llm_suffix: None,
148            };
149        }
150
151        // Validate minimum length
152        if stripped.len() < MIN_PLAN_LENGTH {
153            return ToolResult {
154                success: false,
155                output: Some(format!(
156                    "Plan file exists but contains insufficient content. \
157                     Re-spawn the Planner subagent to write a detailed plan \
158                     to {plan_file_path}."
159                )),
160                error: Some(format!(
161                    "Plan file content is too short ({} chars). \
162                     The Planner subagent likely didn't write a complete plan.",
163                    stripped.len()
164                )),
165                metadata: HashMap::new(),
166                duration_ms: None,
167                llm_suffix: None,
168            };
169        }
170
171        // Validate plan has required delimiters
172        if !plan_content.contains("---BEGIN PLAN---") {
173            return ToolResult {
174                success: false,
175                output: Some(format!(
176                    "Plan file does not follow the required format. \
177                     Re-spawn the Planner subagent and ensure it writes \
178                     the plan with ---BEGIN PLAN--- / ---END PLAN--- delimiters \
179                     to {plan_file_path}."
180                )),
181                error: Some("Plan is missing the required ---BEGIN PLAN--- delimiter.".to_string()),
182                metadata: HashMap::new(),
183                duration_ms: None,
184                llm_suffix: None,
185            };
186        }
187
188        // Validate plan has implementation steps
189        let has_steps = plan_content.contains("## Implementation Steps")
190            || plan_content.contains("## Steps")
191            || plan_content.contains("## implementation steps");
192
193        if !has_steps {
194            return ToolResult {
195                success: false,
196                output: Some(format!(
197                    "Plan file has the delimiters but no '## Implementation Steps' \
198                     with numbered items. Re-spawn the Planner subagent to write \
199                     a properly structured plan to {plan_file_path}."
200                )),
201                error: Some("Plan has no parseable implementation steps.".to_string()),
202                metadata: HashMap::new(),
203                duration_ms: None,
204                llm_suffix: None,
205            };
206        }
207
208        // Validate plan has verification section
209        let has_verification = plan_content.contains("## Verification")
210            || plan_content.contains("## verification")
211            || plan_content.contains("## Testing");
212
213        if !has_verification {
214            return ToolResult {
215                success: false,
216                output: Some(format!(
217                    "Plan needs a '## Verification' section with concrete test commands, \
218                     build/lint checks, and manual verification steps. \
219                     Re-spawn the Planner subagent to improve the verification section \
220                     in {plan_file_path}."
221                )),
222                error: Some("Plan verification section is missing or too brief.".to_string()),
223                metadata: HashMap::new(),
224                duration_ms: None,
225                llm_suffix: None,
226            };
227        }
228
229        // --- Interactive approval: block until user decides ---
230        let auto_approve_mode;
231        if let Some(ref tx) = self.approval_tx {
232            let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
233            if tx
234                .send(PlanApprovalRequest {
235                    plan_content: plan_content.clone(),
236                    response_tx: resp_tx,
237                })
238                .is_err()
239            {
240                // TUI closed — fall through to auto-approve
241                auto_approve_mode = true;
242            } else {
243                match resp_rx.await {
244                    Ok(decision) => match decision.action.as_str() {
245                        "approve_auto" => {
246                            auto_approve_mode = true;
247                        }
248                        "approve" => {
249                            auto_approve_mode = false;
250                        }
251                        _ => {
252                            // "modify" — user wants revisions
253                            let mut metadata = HashMap::new();
254                            metadata.insert("plan_approved".into(), serde_json::json!(false));
255                            metadata
256                                .insert("requires_modification".into(), serde_json::json!(true));
257                            metadata
258                                .insert("plan_file_path".into(), serde_json::json!(plan_file_path));
259                            if !decision.feedback.is_empty() {
260                                metadata.insert(
261                                    "feedback".into(),
262                                    serde_json::json!(decision.feedback),
263                                );
264                            }
265                            return ToolResult {
266                                success: false,
267                                output: Some(
268                                    "User requested plan revision. Re-spawn the Planner \
269                                     subagent to revise the plan."
270                                        .into(),
271                                ),
272                                error: None,
273                                metadata,
274                                duration_ms: None,
275                                llm_suffix: None,
276                            };
277                        }
278                    },
279                    Err(_) => {
280                        // Channel dropped — fall through to auto-approve
281                        auto_approve_mode = true;
282                    }
283                }
284            }
285        } else {
286            // Headless / non-interactive — auto-approve
287            auto_approve_mode = true;
288        }
289
290        // Clear any previous plan's todos — the LLM will create new ones
291        // via write_todos after approval, with proper hierarchical grouping.
292        if let Some(ref mgr) = self.todo_manager
293            && let Ok(mut todo_mgr) = mgr.lock()
294        {
295            todo_mgr.clear();
296        }
297
298        // Register in PlanIndex and copy plan to ~/.opendev/plans/
299        let plan_name = if let Some(plans_dir) = Self::plans_dir() {
300            let name = opendev_runtime::generate_plan_name(Some(&plans_dir), 50);
301
302            // Copy plan file to plans directory
303            if let Err(e) = std::fs::create_dir_all(&plans_dir) {
304                tracing::warn!("Failed to create plans dir: {e}");
305            } else {
306                let dest = plans_dir.join(format!("{name}.md"));
307                if let Err(e) = std::fs::copy(&plan_path, &dest) {
308                    tracing::warn!("Failed to copy plan to {}: {e}", dest.display());
309                }
310            }
311
312            // Register in index
313            let index = PlanIndex::new(&plans_dir);
314            let session_id = ctx.session_id.as_deref();
315            let project_path = Some(ctx.working_dir.to_string_lossy().to_string());
316            index.add_entry(&name, session_id, project_path.as_deref());
317
318            Some(name)
319        } else {
320            None
321        };
322
323        // Build metadata
324        let mut metadata = HashMap::new();
325        metadata.insert("plan_approved".into(), serde_json::json!(true));
326        metadata.insert("auto_approve".into(), serde_json::json!(auto_approve_mode));
327        metadata.insert("plan_file_path".into(), serde_json::json!(plan_file_path));
328        metadata.insert("plan_length".into(), serde_json::json!(plan_content.len()));
329        metadata.insert("plan_content".into(), serde_json::json!(plan_content));
330
331        if let Some(ref name) = plan_name {
332            metadata.insert("plan_name".into(), serde_json::json!(name));
333        }
334
335        let plan_name_display = plan_name
336            .as_deref()
337            .map(|n| format!(" ({n})"))
338            .unwrap_or_default();
339
340        ToolResult::ok_with_metadata(
341            format!(
342                "Plan approved{plan_name_display} ({} chars). \
343                 Proceed with implementation.\n\n\
344                 Plan file: {plan_file_path}",
345                plan_content.len()
346            ),
347            metadata,
348        )
349    }
350}
351
352/// Expand `~` to the home directory.
353fn expand_tilde(path: &str) -> PathBuf {
354    if path.starts_with("~/")
355        && let Some(home) = dirs::home_dir()
356    {
357        return home.join(&path[2..]);
358    }
359    PathBuf::from(path)
360}
361
362#[cfg(test)]
363#[path = "present_plan_tests.rs"]
364mod tests;