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::tool::{Content, Tool, ToolContext};
8use crate::types::{AgentResult, RuntimeEvent, UpdatePlanArgs};
9
10/// A lightweight tool that records and displays a plan checklist to the user.
11///
12/// # Behavior
13///
14/// - Validates the incoming `UpdatePlanArgs` (non-empty plan, at most one
15///   `in_progress` step, no empty step text).
16/// - Broadcasts a [`RuntimeEvent::PlanUpdated`] event for UI rendering.
17/// - Returns a short summary — it does NOT store, execute, or drive the plan.
18///
19/// This is a **display-only** protocol, inspired by Codex's `update_plan`.
20pub struct UpdatePlanTool {
21    last_objective: Mutex<Option<String>>,
22    custom_description: Option<&'static str>,
23}
24
25/// Normalize step text from LLM output for consistent UI rendering.
26///
27/// - Strips LLM-added numbering prefixes ("Step 1: ", "1. ", "1) ", "(1) ",
28///   "第1步:", "第一步:")
29/// - Truncates to max 60 chars (terminal-friendly)
30/// - Strips leading/trailing whitespace
31/// - Falls back to raw text if normalization produces empty string
32fn normalize_step_text(raw: &str) -> String {
33    let text = raw.trim();
34
35    // Step 1: Strip "Step N: " / "step N. " prefix (English)
36    let text = if let Some(rest) = text
37        .strip_prefix("Step")
38        .or_else(|| text.strip_prefix("step"))
39    {
40        let rest = rest.trim_start_matches(|c: char| c.is_ascii_digit() || c == ' ');
41        rest.trim_start_matches([':', '.', ')', ' '])
42    } else {
43        text
44    };
45
46    // Step 2: Strip Chinese "第N步" / "第 N 步:" / "第一步:" patterns
47    let text = if let Some(rest) = text.strip_prefix('第') {
48        // Strip digits, spaces, and common Chinese number characters
49        let rest = rest.trim_start_matches(|c: char| {
50            c.is_ascii_digit()
51                || c == ' '
52                || matches!(
53                    c,
54                    '一' | '二' | '三' | '四' | '五' | '六' | '七' | '八' | '九' | '十'
55                )
56        });
57        // Strip "步" and following punctuation/whitespace
58        rest.strip_prefix('步')
59            .map(|r| r.trim_start_matches([':', ':', '、', '.', ')', ' ']))
60            .unwrap_or(text)
61    } else {
62        text
63    };
64
65    // Step 3: Strip bare number prefixes: "1. ", "1) ", "(1) ", "1-2) ", "3/5) ", "1、"
66    let text = text.trim_start_matches(|c: char| {
67        c.is_ascii_digit() || matches!(c, '.' | ')' | '(' | '、' | ' ' | '-' | '/')
68    });
69
70    // Step 4: Truncate to 60 chars max
71    let text = if text.chars().count() > 60 {
72        let truncated: String = text.chars().take(57).collect();
73        format!("{truncated}...")
74    } else {
75        text.to_string()
76    };
77
78    // Step 5: Final trim — avoid redundant allocation when no trimming needed
79    let trimmed = text.trim();
80    if trimmed.is_empty() {
81        raw.trim().to_string()
82    } else if trimmed.len() < text.len() {
83        trimmed.to_string()
84    } else {
85        text
86    }
87}
88
89impl UpdatePlanTool {
90    pub fn new() -> Self {
91        Self {
92            last_objective: Mutex::new(None),
93            custom_description: None,
94        }
95    }
96
97    /// Override the tool description with a custom string.
98    ///
99    /// The default description is written for the generic agent-base framework.
100    /// Consumers like ops-agent can use this to inject domain-specific usage
101    /// guidelines (e.g. step granularity rules, when to use/not use plans).
102    pub fn with_description(mut self, desc: String) -> Self {
103        self.custom_description = Some(Box::leak(desc.into_boxed_str()));
104        self
105    }
106}
107
108impl Default for UpdatePlanTool {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114#[async_trait]
115impl Tool for UpdatePlanTool {
116    fn name(&self) -> &'static str {
117        "update_plan"
118    }
119
120    fn description(&self) -> &'static str {
121        self.custom_description.unwrap_or(
122            "Record and display a structured plan / checklist to track progress on a complex task.\n\n\
123            This is a presentation-only protocol: it shows the user what you intend to do and \
124            updates step statuses as you work. It does not store or execute anything.\n\n\
125            [When to Use]\n\
126            - Complex tasks (usually 3+ steps): call update_plan first to show the plan, then execute step by step.\n\
127            - Simple tasks, Q&A, one-shot operations: do NOT call — handle directly.\n\n\
128            [Requirements]\n\
129            - Always include the user's goal as `objective` (mandatory on the first call).\n\
130            - `plan` is a full snapshot, not an incremental patch; call again whenever step statuses change.\n\
131            - At most one step may be `in_progress` at a time.\n\
132            - Step text should be concise, human-readable task descriptions.\n\n\
133            [Update Conventions]\n\
134            - Update status promptly as you progress: pending → in_progress → completed.\n\
135            - If blocked, explain the reason honestly in `explanation`.\n\n\
136            [Planning Principles]\n\
137            1. Investigate first — confirm the relevant components and dependencies are ready.\n\
138            2. Order by dependency — run what others depend on first; independent steps may parallelize.\n\
139            3. Close each step — a step should be verifiable on its own, not need the next step to know it worked.\n\
140            4. Flag risk — note when a step touches rm/kill/restart or config changes.\n\
141            5. Right granularity — each step is a minimal, independently-verifiable unit.\n\
142            6. Stop converging — too many steps means the task should be split or discussed first.",
143        )
144    }
145
146    fn schema(&self) -> Value {
147        json!({
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    fn metadata(&self) -> crate::tool::ToolMetadata {
185        crate::tool::ToolMetadata {
186            name: self.name().to_string(),
187            description: "Create or update a task plan to show the user a checklist with progress."
188                .to_string(),
189            origin: "agent-base".to_string(),
190            version: env!("CARGO_PKG_VERSION").to_string(),
191            requirements: vec![],
192        }
193    }
194
195    async fn call(&self, args: &Value, ctx: &ToolContext) -> AgentResult<Vec<Content>> {
196        let plan_args: UpdatePlanArgs = serde_json::from_value(args.clone()).map_err(|e| {
197            crate::types::AgentError::ToolArgsInvalid {
198                name: "update_plan".to_string(),
199                raw: format!("deserialization error: {e}"),
200            }
201        })?;
202
203        // Validate
204        if let Err(validation_err) = plan_args.validate() {
205            return Err(crate::types::AgentError::ToolArgsInvalid {
206                name: "update_plan".to_string(),
207                raw: validation_err,
208            });
209        }
210
211        // Resolve objective: use provided value, or fall back to last known
212        let objective = match plan_args.objective {
213            Some(ref obj) => {
214                *self.last_objective.lock().unwrap() = Some(obj.clone());
215                obj.clone()
216            }
217            None => self
218                .last_objective
219                .lock()
220                .unwrap()
221                .clone()
222                .unwrap_or_else(|| "(no objective specified)".to_string()),
223        };
224
225        // Normalize step text for consistent UI rendering
226        let normalized_plan: Vec<crate::types::PlanItem> = plan_args
227            .plan
228            .into_iter()
229            .map(|item| crate::types::PlanItem {
230                step: normalize_step_text(&item.step),
231                status: item.status,
232            })
233            .collect();
234
235        // Count steps by status for summary
236        let total = normalized_plan.len();
237        let completed = normalized_plan
238            .iter()
239            .filter(|item| item.status == crate::types::PlanStepStatus::Completed)
240            .count();
241        let in_progress = normalized_plan
242            .iter()
243            .filter(|item| item.status == crate::types::PlanStepStatus::InProgress)
244            .count();
245
246        // Build the summary BEFORE emitting the event (so we can move
247        // normalized_plan into the event instead of cloning it).
248        let mut summary = format!("📋 {}: {}/{} steps completed", objective, completed, total);
249        if in_progress > 0 {
250            let current = normalized_plan
251                .iter()
252                .find(|item| item.status == crate::types::PlanStepStatus::InProgress);
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        ctx.event_bus.emit(RuntimeEvent::PlanUpdated {
263            session_id: ctx.session_id.clone(),
264            objective: objective.clone(),
265            explanation: plan_args.explanation.clone(),
266            plan: normalized_plan,
267            agent_id: None,
268            trace_id: None,
269        });
270
271        Ok(vec![Content::text(summary)])
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::tool::content_text;
279    use crate::types::{AgentError, PlanItem, PlanStepStatus};
280
281    #[test]
282    fn test_update_plan_args_validation() {
283        // Valid plan (with objective)
284        let args = UpdatePlanArgs {
285            objective: Some("安装 Docker".into()),
286            explanation: None,
287            plan: vec![
288                PlanItem {
289                    step: "Step 1".into(),
290                    status: PlanStepStatus::Completed,
291                },
292                PlanItem {
293                    step: "Step 2".into(),
294                    status: PlanStepStatus::InProgress,
295                },
296                PlanItem {
297                    step: "Step 3".into(),
298                    status: PlanStepStatus::Pending,
299                },
300            ],
301        };
302        assert!(args.validate().is_ok());
303
304        // Valid plan (no objective — should be allowed)
305        let args = UpdatePlanArgs {
306            objective: None,
307            explanation: None,
308            plan: vec![PlanItem {
309                step: "Step 1".into(),
310                status: PlanStepStatus::Pending,
311            }],
312        };
313        assert!(args.validate().is_ok());
314
315        // Empty objective (provided but blank — should fail)
316        let args = UpdatePlanArgs {
317            objective: Some("".into()),
318            explanation: None,
319            plan: vec![PlanItem {
320                step: "Step 1".into(),
321                status: PlanStepStatus::Pending,
322            }],
323        };
324        assert!(args.validate().is_err());
325
326        // Empty plan
327        let args = UpdatePlanArgs {
328            objective: Some("安装 Docker".into()),
329            explanation: None,
330            plan: vec![],
331        };
332        assert!(args.validate().is_err());
333
334        // Multiple in_progress
335        let args = UpdatePlanArgs {
336            objective: Some("安装 Docker".into()),
337            explanation: None,
338            plan: vec![
339                PlanItem {
340                    step: "Step 1".into(),
341                    status: PlanStepStatus::InProgress,
342                },
343                PlanItem {
344                    step: "Step 2".into(),
345                    status: PlanStepStatus::InProgress,
346                },
347            ],
348        };
349        assert!(args.validate().is_err());
350
351        // Empty step text
352        let args = UpdatePlanArgs {
353            objective: Some("安装 Docker".into()),
354            explanation: None,
355            plan: vec![PlanItem {
356                step: "  ".into(),
357                status: PlanStepStatus::Pending,
358            }],
359        };
360        assert!(args.validate().is_err());
361    }
362
363    #[test]
364    fn test_normalize_step_text() {
365        // Strip number prefixes
366        assert_eq!(normalize_step_text("1. 安装 Docker"), "安装 Docker");
367        assert_eq!(normalize_step_text("2) 添加 GPG 密钥"), "添加 GPG 密钥");
368        assert_eq!(
369            normalize_step_text("(3) 更新 APT 包列表"),
370            "更新 APT 包列表"
371        );
372        assert_eq!(normalize_step_text("Step 1: 安装 Docker"), "安装 Docker");
373        assert_eq!(normalize_step_text("step 2: 更新包列表"), "更新包列表");
374        assert_eq!(normalize_step_text("1、配置仓库"), "配置仓库");
375
376        // Strip hyphenated and slashed number prefixes
377        assert_eq!(normalize_step_text("1-2) Install Docker"), "Install Docker");
378        assert_eq!(normalize_step_text("3/5) Verify config"), "Verify config");
379
380        // Strip Chinese numbering prefixes
381        assert_eq!(normalize_step_text("第一步:安装 Docker"), "安装 Docker");
382        assert_eq!(normalize_step_text("第1步:添加 GPG 密钥"), "添加 GPG 密钥");
383        assert_eq!(normalize_step_text("第 3 步: 更新包列表"), "更新包列表");
384        assert_eq!(normalize_step_text("第二步、配置仓库"), "配置仓库");
385
386        // Strip "第" without "步" gracefully (revert to original)
387        assert_eq!(normalize_step_text("第一个任务:安装"), "第一个任务:安装");
388
389        // Truncate long text
390        let long = "使用 apt install -y docker-ce docker-ce-cli containerd.io 命令来安装 Docker 引擎以及相关组件";
391        let result = normalize_step_text(long);
392        assert!(result.chars().count() <= 60);
393        assert!(result.ends_with("..."));
394
395        // Preserve short text
396        assert_eq!(normalize_step_text("安装 Docker 引擎"), "安装 Docker 引擎");
397
398        // Strip whitespace
399        assert_eq!(normalize_step_text("  安装 Docker  "), "安装 Docker");
400
401        // Fallback: don't return empty
402        let result = normalize_step_text("123");
403        assert!(!result.is_empty());
404    }
405
406    // ── B4: accessors + call orchestration ────────────────────────────────
407
408    #[test]
409    fn accessors_name_schema_metadata() {
410        let t = UpdatePlanTool::new();
411        assert_eq!(t.name(), "update_plan");
412        assert_eq!(t.schema()["required"], json!(["plan"]));
413        let m = t.metadata();
414        assert_eq!(m.name, "update_plan");
415        assert_eq!(m.origin, "agent-base");
416        assert!(m.requirements.is_empty());
417    }
418
419    #[test]
420    fn with_description_overrides_default() {
421        let t = UpdatePlanTool::new().with_description("custom desc".to_string());
422        assert_eq!(t.description(), "custom desc");
423
424        let t = UpdatePlanTool::new();
425        assert!(
426            t.description()
427                .contains("Record and display a structured plan")
428        );
429    }
430
431    #[tokio::test]
432    async fn call_with_full_plan_builds_summary_with_current() {
433        let t = UpdatePlanTool::new();
434        let ctx = ToolContext::for_test();
435        let args = json!({
436            "objective": "Install Docker",
437            "plan": [
438                {"step": "Install Docker", "status": "completed"},
439                {"step": "Add GPG key", "status": "in_progress"},
440                {"step": "Update packages", "status": "pending"}
441            ]
442        });
443        let out = t.call(&args, &ctx).await.unwrap();
444        let text = content_text(&out);
445        assert!(
446            text.contains("Install Docker: 1/3 steps completed"),
447            "{text}"
448        );
449        assert!(text.contains("Current: \"Add GPG key\""), "{text}");
450    }
451
452    #[tokio::test]
453    async fn call_all_completed_emits_completion() {
454        let t = UpdatePlanTool::new();
455        let ctx = ToolContext::for_test();
456        let args = json!({
457            "objective": "Install Docker",
458            "plan": [{"step": "Install Docker", "status": "completed"}]
459        });
460        let out = t.call(&args, &ctx).await.unwrap();
461        assert!(content_text(&out).contains("all steps completed"));
462    }
463
464    #[tokio::test]
465    async fn call_remembers_last_objective() {
466        let t = UpdatePlanTool::new();
467        let ctx = ToolContext::for_test();
468
469        let args = json!({
470            "objective": "Install Docker",
471            "plan": [{"step": "Install Docker", "status": "completed"}]
472        });
473        let _ = t.call(&args, &ctx).await.unwrap();
474
475        let args = json!({
476            "plan": [{"step": "Add GPG key", "status": "in_progress"}]
477        });
478        let out = t.call(&args, &ctx).await.unwrap();
479        assert!(content_text(&out).contains("Install Docker"));
480    }
481
482    #[tokio::test]
483    async fn call_without_objective_uses_placeholder() {
484        let t = UpdatePlanTool::new();
485        let ctx = ToolContext::for_test();
486        let args = json!({
487            "plan": [{"step": "Install Docker", "status": "completed"}]
488        });
489        let out = t.call(&args, &ctx).await.unwrap();
490        assert!(content_text(&out).contains("(no objective specified)"));
491    }
492
493    #[tokio::test]
494    async fn call_empty_plan_is_invalid() {
495        let t = UpdatePlanTool::new();
496        let ctx = ToolContext::for_test();
497        let args = json!({"objective": "x", "plan": []});
498        let err = t.call(&args, &ctx).await.unwrap_err();
499        assert!(matches!(err, AgentError::ToolArgsInvalid { .. }));
500    }
501}