Skip to main content

j_agent/tools/
plan.rs

1use crate::agent::thread_identity::current_agent_name;
2use crate::message_types::PlanDecision;
3use crate::message_types::{AskOption, AskQuestion, AskRequest};
4use crate::permission::JcliConfig;
5use crate::tools::{Tool, ToolResult, schema_to_tool_params};
6use schemars::JsonSchema;
7use serde::Deserialize;
8use serde_json::Value;
9use std::borrow::Cow;
10use std::sync::{Arc, atomic::AtomicBool, mpsc};
11
12// Re-export plan state types from context module
13pub use crate::context::plan_state::{
14    PendingPlanApproval, PlanApprovalQueue, PlanModeState, is_allowed_in_plan_mode,
15};
16
17// ========== EnterPlanModeTool ==========
18
19/// 将描述文本转为安全的文件名(只保留字母数字、中文、下划线、短横线)
20fn sanitize_filename(s: &str) -> String {
21    s.chars()
22        .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c > '\u{4e00}')
23        .collect::<String>()
24        .trim()
25        .to_string()
26}
27
28/// EnterPlanMode 参数
29#[derive(Deserialize, JsonSchema)]
30struct EnterPlanModeParams {
31    /// Short description used as the plan file name (e.g. "add-auth" becomes plan-add-auth.md)
32    #[serde(default)]
33    description: Option<String>,
34}
35
36/// 进入计划模式工具,用于在编写代码前探索代码库并设计实现方案
37#[derive(Debug)]
38pub struct EnterPlanModeTool {
39    /// 计划模式的全局共享状态
40    pub plan_state: Arc<PlanModeState>,
41}
42
43impl EnterPlanModeTool {
44    pub const NAME: &'static str = "EnterPlanMode";
45}
46
47impl Tool for EnterPlanModeTool {
48    fn name(&self) -> &str {
49        Self::NAME
50    }
51
52    fn description(&self) -> Cow<'_, str> {
53        r#"
54        Enter plan mode to explore the codebase and design an implementation approach before writing code.
55        In plan mode, only read-only tools (Read, Glob, Grep, WebFetch, WebSearch, Ask, etc.) are available.
56        Write tools (Shell, Write, Edit, etc.) will be blocked until plan mode is exited.
57
58        Use this proactively before starting non-trivial implementation tasks. Prefer using EnterPlanMode when ANY of these apply:
59        - New feature implementation with architectural decisions
60        - Multiple valid approaches exist and user should choose
61        - Code modifications that affect existing behavior
62        - Multi-file changes (touching more than 2-3 files)
63        - Unclear requirements that need exploration first
64
65        Do NOT use for: single-line fixes, typos, or purely research/exploration tasks.
66
67        The `description` parameter is used as the plan file name (e.g. "add-auth" → plan-add-auth.md).
68        If a plan file with the same name already exists, you will be warned so you can choose a different name.
69        Plan files are preserved after exiting plan mode for future reference.
70        "#.into()
71    }
72
73    fn parameters_schema(&self) -> Value {
74        schema_to_tool_params::<EnterPlanModeParams>()
75    }
76
77    fn execute(&self, arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
78        let params: EnterPlanModeParams =
79            serde_json::from_str(arguments).unwrap_or(EnterPlanModeParams { description: None });
80        let description = params
81            .description
82            .as_deref()
83            .unwrap_or("implementation-plan");
84
85        // 创建 plan 目录
86        let plan_dir = JcliConfig::ensure_config_dir()
87            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join(".jcli"));
88        let plans_dir = plan_dir.join("plans");
89        let _ = std::fs::create_dir_all(&plans_dir);
90
91        // 基于描述生成文件名(如 plan-add-auth.md)
92        let safe_name = sanitize_filename(description);
93        let file_name = if safe_name.is_empty() {
94            format!("plan-{}.md", std::process::id())
95        } else {
96            format!("plan-{}.md", safe_name)
97        };
98        let plan_file = plans_dir.join(&file_name);
99        let plan_path = plan_file.display().to_string();
100
101        // 检查同名文件是否已存在
102        let mut warning = String::new();
103        if plan_file.exists() {
104            match std::fs::read_to_string(&plan_file) {
105                Ok(existing) => {
106                    // 提取已有 plan 的第一行标题作为摘要
107                    let first_line = existing.lines().next().unwrap_or("");
108                    warning = format!(
109                        "⚠️ Plan file already exists: {} (content starts with: {})\n\
110                         The existing file will be overwritten. Consider using a different description to avoid this.\n\n",
111                        plan_path, first_line
112                    );
113                }
114                Err(_) => {
115                    warning = format!(
116                        "⚠️ Plan file already exists: {}\n\
117                         The existing file will be overwritten. Consider using a different description to avoid this.\n\n",
118                        plan_path
119                    );
120                }
121            }
122        }
123
124        // 写入初始模板
125        let template = format!("# Plan: {}\n\n## Steps\n\n1. \n\n## Notes\n\n", description);
126        let _ = std::fs::write(&plan_file, &template);
127
128        // 原子性地进入 plan mode
129        match self.plan_state.enter(plan_path.clone()) {
130            Ok(()) => ToolResult {
131                output: format!(
132                    "{}Entered plan mode. Plan file: {}\n\
133                     In plan mode, only read-only tools are available.\n\
134                     Write your plan to the plan file, then use ExitPlanMode when ready for user approval.\n\
135                     Plan files are preserved after exit for future reference.",
136                    warning, plan_path
137                ),
138                is_error: false,
139                images: vec![],
140                plan_decision: PlanDecision::None,
141            },
142            Err(msg) => ToolResult {
143                output: msg,
144                is_error: false,
145                images: vec![],
146                plan_decision: PlanDecision::None,
147            },
148        }
149    }
150
151    fn requires_confirmation(&self) -> bool {
152        false
153    }
154}
155
156// ========== ExitPlanModeTool ==========
157
158/// ExitPlanMode 参数
159#[derive(Deserialize, JsonSchema)]
160#[allow(dead_code)]
161struct ExitPlanModeParams {
162    /// Optional list of prompt-based permissions needed to implement the plan
163    #[serde(default)]
164    #[serde(rename = "allowedPrompts")]
165    allowed_prompts: Option<Vec<AllowedPrompt>>,
166}
167
168/// 计划实施所需的权限描述
169#[derive(Deserialize, JsonSchema)]
170#[allow(dead_code)]
171struct AllowedPrompt {
172    /// The tool this prompt applies to (e.g. 'Bash')
173    #[serde(default)]
174    tool: Option<String>,
175    /// Semantic description of the action (e.g. 'run tests')
176    #[serde(default)]
177    prompt: Option<String>,
178}
179
180/// 退出计划模式工具,读取计划文件并提交用户审批
181pub struct ExitPlanModeTool {
182    /// 计划模式的全局共享状态
183    pub plan_state: Arc<PlanModeState>,
184    /// 用于向主线程发送提问请求的通道发送端
185    pub ask_tx: mpsc::Sender<AskRequest>,
186    /// Plan 审批队列(teammate 通过此队列路由审批请求到主 TUI)
187    pub plan_approval_queue: Option<Arc<PlanApprovalQueue>>,
188}
189
190impl ExitPlanModeTool {
191    pub const NAME: &'static str = "ExitPlanMode";
192}
193
194impl Tool for ExitPlanModeTool {
195    fn name(&self) -> &str {
196        Self::NAME
197    }
198
199    fn description(&self) -> Cow<'_, str> {
200        r#"
201        Exit plan mode and submit the plan for user approval.
202        Reads the plan file and presents it to the user for review.
203        If approved, plan mode is deactivated and write tools become available again.
204        If rejected, plan mode remains active so you can revise the plan.
205        "#
206        .into()
207    }
208
209    fn parameters_schema(&self) -> Value {
210        schema_to_tool_params::<ExitPlanModeParams>()
211    }
212
213    fn execute(&self, _arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
214        if !self.plan_state.is_active() {
215            return ToolResult {
216                output: "Not in plan mode. Use EnterPlanMode first.".to_string(),
217                is_error: true,
218                images: vec![],
219                plan_decision: PlanDecision::None,
220            };
221        }
222
223        // 读取 plan 文件内容
224        let plan_content = match self.plan_state.get_plan_file_path() {
225            Some(path) => match std::fs::read_to_string(&path) {
226                Ok(content) => content,
227                Err(e) => {
228                    return ToolResult {
229                        output: format!("Failed to read plan file: {}", e),
230                        is_error: true,
231                        images: vec![],
232                        plan_decision: PlanDecision::None,
233                    };
234                }
235            },
236            None => {
237                return ToolResult {
238                    output: "No plan file path set.".to_string(),
239                    is_error: true,
240                    images: vec![],
241                    plan_decision: PlanDecision::None,
242                };
243            }
244        };
245
246        // 判断是否在 teammate 线程中(非 Main agent)
247        let agent_name = current_agent_name();
248        if agent_name != "Main" {
249            // Teammate 模式:通过 PlanApprovalQueue 路由到主 TUI
250            if let Some(ref queue) = self.plan_approval_queue {
251                // 从 plan 文件路径提取计划名
252                let plan_file_path = self.plan_state.get_plan_file_path().unwrap_or_default();
253                let plan_name = std::path::Path::new(&plan_file_path)
254                    .file_stem()
255                    .and_then(|s| s.to_str())
256                    .and_then(|s| s.strip_prefix("plan-"))
257                    .unwrap_or("Plan")
258                    .to_string();
259
260                let req = PendingPlanApproval::new(agent_name, plan_content.clone(), plan_name);
261                let decision = queue.request_blocking(req);
262
263                match decision {
264                    PlanDecision::Approve => {
265                        let plan_file_path = self.plan_state.get_plan_file_path();
266                        self.plan_state.exit();
267                        let preserved_msg = plan_file_path
268                            .as_deref()
269                            .map(|p| format!("\nPlan file preserved at: {}", p))
270                            .unwrap_or_default();
271                        ToolResult {
272                            output: format!(
273                                "Plan approved! Exited plan mode. You can now proceed with implementation.{}\n\n**Plan Content:**\n\n{}",
274                                preserved_msg, plan_content
275                            ),
276                            is_error: false,
277                            images: vec![],
278                            plan_decision: PlanDecision::Approve,
279                        }
280                    }
281                    PlanDecision::ApproveAndClearContext => {
282                        let plan_file_path = self.plan_state.get_plan_file_path();
283                        self.plan_state.exit();
284                        let preserved_msg = plan_file_path
285                            .as_deref()
286                            .map(|p| format!("\nPlan file preserved at: {}", p))
287                            .unwrap_or_default();
288                        ToolResult {
289                            output: format!(
290                                "Plan approved with context clear! Exited plan mode.{}\n\n**Plan Content:**\n\n{}",
291                                preserved_msg, plan_content
292                            ),
293                            is_error: false,
294                            images: vec![],
295                            plan_decision: PlanDecision::ApproveAndClearContext,
296                        }
297                    }
298                    PlanDecision::Reject | PlanDecision::None => {
299                        ToolResult {
300                            output: "Plan was not approved. Still in plan mode. Please revise your plan and try ExitPlanMode again.".to_string(),
301                            is_error: false,
302                            images: vec![],
303                            plan_decision: PlanDecision::Reject,
304                        }
305                    }
306                }
307            } else {
308                // 无队列(不应该出现),回退错误
309                ToolResult {
310                    output: "Plan approval not available in sub-agent mode (no queue). Add permission rules to avoid plan mode in teammates.".to_string(),
311                    is_error: true,
312                    images: vec![],
313                    plan_decision: PlanDecision::None,
314                }
315            }
316        } else {
317            // 主 agent 模式:走原有的 ask_tx 通道
318            self.execute_via_ask_tx(&plan_content)
319        }
320    }
321
322    fn requires_confirmation(&self) -> bool {
323        false
324    }
325}
326
327impl ExitPlanModeTool {
328    /// 主 agent 通过 ask_tx 通道审批
329    fn execute_via_ask_tx(&self, plan_content: &str) -> ToolResult {
330        // 通过 Ask 机制发送审批请求
331        let (response_tx, response_rx) = mpsc::channel::<String>();
332
333        let question_text = format!("请审阅以下实施计划,选择操作:\n\n{}", plan_content);
334
335        // 从 plan 文件路径提取计划名(plan-xxx.md → xxx)
336        let plan_file_path = self.plan_state.get_plan_file_path().unwrap_or_default();
337        let plan_name = std::path::Path::new(&plan_file_path)
338            .file_stem()
339            .and_then(|s| s.to_str())
340            .and_then(|s| s.strip_prefix("plan-"))
341            .unwrap_or("Plan");
342
343        let ask_request = AskRequest {
344            questions: vec![AskQuestion {
345                question: question_text,
346                header: plan_name.to_string(),
347                options: vec![
348                    AskOption {
349                        label: "批准计划".to_string(),
350                        description: "批准此计划,保留当前上下文,开始实施".to_string(),
351                    },
352                    AskOption {
353                        label: "批准并清空上下文".to_string(),
354                        description: "批准计划并清空探索过程中的对话上下文,仅保留计划内容继续实施"
355                            .to_string(),
356                    },
357                    AskOption {
358                        label: "驳回计划".to_string(),
359                        description: "拒绝此计划,留在 Plan Mode 中修改方案".to_string(),
360                    },
361                ],
362                multi_select: false,
363            }],
364            response_tx,
365        };
366
367        if self.ask_tx.send(ask_request).is_err() {
368            return ToolResult {
369                output: "Failed to send approval request (main thread may have exited)".to_string(),
370                is_error: true,
371                images: vec![],
372                plan_decision: PlanDecision::None,
373            };
374        }
375
376        // 阻塞等待用户审批结果
377        match response_rx.recv() {
378            Ok(response) => {
379                if response.contains("批准并清空上下文") {
380                    let plan_file_path = self.plan_state.get_plan_file_path();
381                    self.plan_state.exit();
382                    let preserved_msg = plan_file_path
383                        .as_deref()
384                        .map(|p| format!("\nPlan file preserved at: {}", p))
385                        .unwrap_or_default();
386                    ToolResult {
387                        output: format!(
388                            "Plan approved with context clear! Exited plan mode.{}\n\n**Plan Content:**\n\n{}",
389                            preserved_msg, plan_content
390                        ),
391                        is_error: false,
392                        images: vec![],
393                        plan_decision: PlanDecision::ApproveAndClearContext,
394                    }
395                } else if response.contains("批准") {
396                    let plan_file_path = self.plan_state.get_plan_file_path();
397                    self.plan_state.exit();
398                    let preserved_msg = plan_file_path
399                        .as_deref()
400                        .map(|p| format!("\nPlan file preserved at: {}", p))
401                        .unwrap_or_default();
402                    ToolResult {
403                        output: format!(
404                            "Plan approved! Exited plan mode. You can now proceed with implementation.{}\n\n**Plan Content:**\n\n{}",
405                            preserved_msg, plan_content
406                        ),
407                        is_error: false,
408                        images: vec![],
409                        plan_decision: PlanDecision::Approve,
410                    }
411                } else {
412                    // 保持 plan mode,让 agent 修改 plan
413                    ToolResult {
414                        output: format!(
415                            "Plan was not approved. Still in plan mode. User response: {}\nPlease revise your plan and try ExitPlanMode again.",
416                            response
417                        ),
418                        is_error: false,
419                        images: vec![],
420                        plan_decision: PlanDecision::Reject,
421                    }
422                }
423            }
424            Err(_) => ToolResult {
425                output: "Connection lost while waiting for approval".to_string(),
426                is_error: true,
427                images: vec![],
428                plan_decision: PlanDecision::None,
429            },
430        }
431    }
432}