Skip to main content

agent_base/tool/
update_plan.rs

1use std::fmt::Write;
2use std::sync::Mutex;
3
4use async_trait::async_trait;
5use serde_json::{json, Value};
6
7use crate::engine::EventBus;
8use crate::tool::{FrameworkTool, Tool, ToolContext, ToolOutput};
9use crate::types::{AgentResult, RuntimeEvent, UpdatePlanArgs};
10
11/// A lightweight tool that records and displays a plan checklist to the user.
12///
13/// # Behavior
14///
15/// - Validates the incoming `UpdatePlanArgs` (non-empty plan, at most one
16///   `in_progress` step, no empty step text).
17/// - Broadcasts a [`RuntimeEvent::PlanUpdated`] event for UI rendering.
18/// - Returns a short summary — it does NOT store, execute, or drive the plan.
19///
20/// This is a **display-only** protocol, inspired by Codex's `update_plan`.
21pub struct UpdatePlanTool {
22    event_bus: Mutex<Option<EventBus>>,
23    last_objective: Mutex<Option<String>>,
24    custom_description: Option<String>,
25}
26
27/// Normalize step text from LLM output for consistent UI rendering.
28///
29/// - Strips LLM-added numbering prefixes ("Step 1: ", "1. ", "1) ", "(1) ",
30///   "第1步:", "第一步:")
31/// - Truncates to max 60 chars (terminal-friendly)
32/// - Strips leading/trailing whitespace
33/// - Falls back to raw text if normalization produces empty string
34fn normalize_step_text(raw: &str) -> String {
35    let text = raw.trim();
36
37    // Step 1: Strip "Step N: " / "step N. " prefix (English)
38    let text = if let Some(rest) = text.strip_prefix("Step").or_else(|| text.strip_prefix("step")) {
39        let rest = rest.trim_start_matches(|c: char| c.is_ascii_digit() || c == ' ');
40        rest.trim_start_matches(|c: char| c == ':' || c == '.' || c == ')' || c == ' ')
41    } else {
42        text
43    };
44
45    // Step 2: Strip Chinese "第N步" / "第 N 步:" / "第一步:" patterns
46    let text = if let Some(rest) = text.strip_prefix('第') {
47        // Strip digits, spaces, and common Chinese number characters
48        let rest = rest.trim_start_matches(|c: char| {
49            c.is_ascii_digit()
50                || c == ' '
51                || matches!(c, '一' | '二' | '三' | '四' | '五' | '六' | '七' | '八' | '九' | '十')
52        });
53        // Strip "步" and following punctuation/whitespace
54        rest.strip_prefix('步')
55            .map(|r| r.trim_start_matches(|c: char| matches!(c, ':' | ':' | '、' | '.' | ')' | ' ')))
56            .unwrap_or(text)
57    } else {
58        text
59    };
60
61    // Step 3: Strip bare number prefixes: "1. ", "1) ", "(1) ", "1-2) ", "3/5) ", "1、"
62    let text = text.trim_start_matches(|c: char| {
63        c.is_ascii_digit() || matches!(c, '.' | ')' | '(' | '、' | ' ' | '-' | '/')
64    });
65
66    // Step 4: Truncate to 60 chars max
67    let mut text = if text.chars().count() > 60 {
68        let truncated: String = text.chars().take(57).collect();
69        format!("{truncated}...")
70    } else {
71        text.to_string()
72    };
73
74    // Step 5: Final trim — avoid redundant allocation when no trimming needed
75    let trimmed = text.trim();
76    if trimmed.is_empty() {
77        raw.trim().to_string()
78    } else if trimmed.len() < text.len() {
79        trimmed.to_string()
80    } else {
81        text
82    }
83}
84
85impl UpdatePlanTool {
86    pub fn new() -> Self {
87        Self {
88            event_bus: Mutex::new(None),
89            last_objective: Mutex::new(None),
90            custom_description: None,
91        }
92    }
93
94    /// Override the tool description with a custom string.
95    ///
96    /// The default description is written for the generic agent-base framework.
97    /// Consumers like ops-agent can use this to inject domain-specific usage
98    /// guidelines (e.g. step granularity rules, when to use/not use plans).
99    pub fn with_description(mut self, desc: String) -> Self {
100        self.custom_description = Some(desc);
101        self
102    }
103}
104
105impl Default for UpdatePlanTool {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl FrameworkTool for UpdatePlanTool {
112    fn set_event_bus(&self, event_bus: EventBus) {
113        *self.event_bus.lock().unwrap() = Some(event_bus);
114    }
115}
116
117#[async_trait]
118impl Tool for UpdatePlanTool {
119    fn name(&self) -> &'static str {
120        "update_plan"
121    }
122
123    fn definition(&self) -> Value {
124        let description = self.custom_description.as_deref().unwrap_or(
125            "Record and display a structured plan / checklist to track progress on a complex task.\n\n\
126            Use this to show the user what steps you plan to take and update step statuses as you go.\n\n\
127            Rules:\n\
128            - Always include the user's goal as `objective`\n\
129            - Plan must have at least one step\n\
130            - At most one step may be in_progress at a time\n\
131            - Step descriptions should be concise and human-readable\n\
132            - Call this again whenever step statuses change\n\
133            - Skip this for simple/trivial tasks\n\n\
134            When creating a plan, follow these principles:\n\
135            1. 探查先行 — 第一步先确认相关组件和依赖是否就绪\n\
136            2. 依赖排序 — 被依赖的先执行,独立步骤可并行但不强制\n\
137            3. 每步闭环 — 一步做完可独立验证结果,不等下步才知道成败\n\
138            4. 标注风险 — 涉及 rm、kill、restart、改配置文件时注明\n\
139            5. 粒度适中 — 不过细也不过大,每步是独立可验证的最小逻辑单元\n\
140            6. 收敛止步 — 步骤过多说明任务需要拆分或先讨论再定"
141        );
142        json!({
143            "type": "function",
144            "function": {
145                "name": "update_plan",
146                "description": description,
147                "parameters": {
148                    "type": "object",
149                    "properties": {
150                        "objective": {
151                            "type": "string",
152                            "description": "One-sentence summary of the user's goal. Example: \"安装 Casdoor 身份认证系统\". Must reflect the user's original intent, not just the current sub-task. Optional on subsequent calls — the tool remembers the last objective."
153                        },
154                        "explanation": {
155                            "type": "string",
156                            "description": "Optional explanation of why the plan is being created or changed."
157                        },
158                        "plan": {
159                            "type": "array",
160                            "description": "The complete plan checklist. Each item has a step description and status.",
161                            "items": {
162                                "type": "object",
163                                "properties": {
164                                    "step": {
165                                        "type": "string",
166                                        "description": "Short description of this step (5-7 words). Example: '安装 Docker 引擎'"
167                                    },
168                                    "status": {
169                                        "type": "string",
170                                        "enum": ["pending", "in_progress", "completed"],
171                                        "description": "Current status of this step."
172                                    }
173                                },
174                                "required": ["step", "status"],
175                                "additionalProperties": false
176                            }
177                        }
178                    },
179                    "required": ["plan"],
180                    "additionalProperties": false
181                }
182            }
183        })
184    }
185
186    fn as_framework_tool(&self) -> Option<&dyn FrameworkTool> {
187        Some(self)
188    }
189
190    async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
191        let plan_args: UpdatePlanArgs = serde_json::from_value(args.clone())
192            .map_err(|e| crate::types::AgentError::ToolArgsInvalid {
193                name: "update_plan".to_string(),
194                raw: format!("deserialization error: {e}"),
195            })?;
196
197        // Validate
198        if let Err(validation_err) = plan_args.validate() {
199            return Err(crate::types::AgentError::ToolArgsInvalid {
200                name: "update_plan".to_string(),
201                raw: validation_err,
202            });
203        }
204
205        // Resolve objective: use provided value, or fall back to last known
206        let objective = match plan_args.objective {
207            Some(ref obj) => {
208                *self.last_objective.lock().unwrap() = Some(obj.clone());
209                obj.clone()
210            }
211            None => {
212                self.last_objective
213                    .lock()
214                    .unwrap()
215                    .clone()
216                    .unwrap_or_else(|| "(no objective specified)".to_string())
217            }
218        };
219
220        // Normalize step text for consistent UI rendering
221        let normalized_plan: Vec<crate::types::PlanItem> = plan_args
222            .plan
223            .into_iter()
224            .map(|item| crate::types::PlanItem {
225                step: normalize_step_text(&item.step),
226                status: item.status,
227            })
228            .collect();
229
230        // Count steps by status for summary
231        let total = normalized_plan.len();
232        let completed = normalized_plan
233            .iter()
234            .filter(|item| item.status == crate::types::PlanStepStatus::Completed)
235            .count();
236        let in_progress = normalized_plan
237            .iter()
238            .filter(|item| item.status == crate::types::PlanStepStatus::InProgress)
239            .count();
240
241        // Build summary and raw output BEFORE emitting event (so we can move
242        // normalized_plan into the event instead of cloning it).
243        let raw = Some(serde_json::to_value(&normalized_plan).unwrap_or_default());
244
245        let mut summary = format!(
246            "📋 {}: {}/{} steps completed",
247            objective, completed, total
248        );
249        if in_progress > 0 {
250            let current = normalized_plan.iter().find(|item| {
251                item.status == crate::types::PlanStepStatus::InProgress
252            });
253            if let Some(item) = current {
254                write!(summary, ". Current: \"{}\"", item.step).unwrap();
255            }
256        }
257        if total == completed {
258            summary = format!("📋 {} — all steps completed!", objective);
259        }
260
261        // Broadcast PlanUpdated event (normalized_plan is moved here, not cloned)
262        {
263            let guard = self.event_bus.lock().unwrap();
264            if let Some(ref event_bus) = *guard {
265                event_bus.emit(RuntimeEvent::PlanUpdated {
266                    session_id: _ctx.session_id.clone(),
267                    objective: objective.clone(),
268                    explanation: plan_args.explanation.clone(),
269                    plan: normalized_plan,
270                });
271            }
272        }
273
274        Ok(ToolOutput {
275            summary,
276            raw,
277            control_flow: crate::tool::ToolControlFlow::Continue,
278            truncation: None,
279        })
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::types::{PlanItem, PlanStepStatus};
287
288    #[test]
289    fn test_update_plan_args_validation() {
290        // Valid plan (with objective)
291        let args = UpdatePlanArgs {
292            objective: Some("安装 Docker".into()),
293            explanation: None,
294            plan: vec![
295                PlanItem { step: "Step 1".into(), status: PlanStepStatus::Completed },
296                PlanItem { step: "Step 2".into(), status: PlanStepStatus::InProgress },
297                PlanItem { step: "Step 3".into(), status: PlanStepStatus::Pending },
298            ],
299        };
300        assert!(args.validate().is_ok());
301
302        // Valid plan (no objective — should be allowed)
303        let args = UpdatePlanArgs {
304            objective: None,
305            explanation: None,
306            plan: vec![
307                PlanItem { step: "Step 1".into(), status: PlanStepStatus::Pending },
308            ],
309        };
310        assert!(args.validate().is_ok());
311
312        // Empty objective (provided but blank — should fail)
313        let args = UpdatePlanArgs {
314            objective: Some("".into()),
315            explanation: None,
316            plan: vec![
317                PlanItem { step: "Step 1".into(), status: PlanStepStatus::Pending },
318            ],
319        };
320        assert!(args.validate().is_err());
321
322        // Empty plan
323        let args = UpdatePlanArgs {
324            objective: Some("安装 Docker".into()),
325            explanation: None,
326            plan: vec![],
327        };
328        assert!(args.validate().is_err());
329
330        // Multiple in_progress
331        let args = UpdatePlanArgs {
332            objective: Some("安装 Docker".into()),
333            explanation: None,
334            plan: vec![
335                PlanItem { step: "Step 1".into(), status: PlanStepStatus::InProgress },
336                PlanItem { step: "Step 2".into(), status: PlanStepStatus::InProgress },
337            ],
338        };
339        assert!(args.validate().is_err());
340
341        // Empty step text
342        let args = UpdatePlanArgs {
343            objective: Some("安装 Docker".into()),
344            explanation: None,
345            plan: vec![
346                PlanItem { step: "  ".into(), status: PlanStepStatus::Pending },
347            ],
348        };
349        assert!(args.validate().is_err());
350    }
351
352    #[test]
353    fn test_normalize_step_text() {
354        // Strip number prefixes
355        assert_eq!(normalize_step_text("1. 安装 Docker"), "安装 Docker");
356        assert_eq!(normalize_step_text("2) 添加 GPG 密钥"), "添加 GPG 密钥");
357        assert_eq!(normalize_step_text("(3) 更新 APT 包列表"), "更新 APT 包列表");
358        assert_eq!(normalize_step_text("Step 1: 安装 Docker"), "安装 Docker");
359        assert_eq!(normalize_step_text("step 2: 更新包列表"), "更新包列表");
360        assert_eq!(normalize_step_text("1、配置仓库"), "配置仓库");
361
362        // Strip hyphenated and slashed number prefixes
363        assert_eq!(normalize_step_text("1-2) Install Docker"), "Install Docker");
364        assert_eq!(normalize_step_text("3/5) Verify config"), "Verify config");
365
366        // Strip Chinese numbering prefixes
367        assert_eq!(normalize_step_text("第一步:安装 Docker"), "安装 Docker");
368        assert_eq!(normalize_step_text("第1步:添加 GPG 密钥"), "添加 GPG 密钥");
369        assert_eq!(normalize_step_text("第 3 步: 更新包列表"), "更新包列表");
370        assert_eq!(normalize_step_text("第二步、配置仓库"), "配置仓库");
371
372        // Strip "第" without "步" gracefully (revert to original)
373        assert_eq!(normalize_step_text("第一个任务:安装"), "第一个任务:安装");
374
375        // Truncate long text
376        let long = "使用 apt install -y docker-ce docker-ce-cli containerd.io 命令来安装 Docker 引擎以及相关组件";
377        let result = normalize_step_text(long);
378        assert!(result.chars().count() <= 60);
379        assert!(result.ends_with("..."));
380
381        // Preserve short text
382        assert_eq!(normalize_step_text("安装 Docker 引擎"), "安装 Docker 引擎");
383
384        // Strip whitespace
385        assert_eq!(normalize_step_text("  安装 Docker  "), "安装 Docker");
386
387        // Fallback: don't return empty
388        let result = normalize_step_text("123");
389        assert!(!result.is_empty());
390    }
391}