1use 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
20const MIN_PLAN_LENGTH: usize = 100;
22
23#[derive(Debug)]
25pub struct PresentPlanTool {
26 todo_manager: Option<Arc<Mutex<TodoManager>>>,
28 approval_tx: Option<PlanApprovalSender>,
32}
33
34impl PresentPlanTool {
35 pub fn new() -> Self {
37 Self {
38 todo_manager: None,
39 approval_tx: None,
40 }
41 }
42
43 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 pub fn with_approval_tx(mut self, tx: PlanApprovalSender) -> Self {
59 self.approval_tx = Some(tx);
60 self
61 }
62
63 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 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 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 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 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 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 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 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 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 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 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 auto_approve_mode = true;
282 }
283 }
284 }
285 } else {
286 auto_approve_mode = true;
288 }
289
290 if let Some(ref mgr) = self.todo_manager
293 && let Ok(mut todo_mgr) = mgr.lock()
294 {
295 todo_mgr.clear();
296 }
297
298 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 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 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 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
352fn 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;