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