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