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
10pub struct UpdatePlanTool {
21 last_objective: Mutex<Option<String>>,
22 custom_description: Option<&'static str>,
23}
24
25fn normalize_step_text(raw: &str) -> String {
33 let text = raw.trim();
34
35 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 let text = if let Some(rest) = text.strip_prefix('第') {
48 let rest = rest.trim_start_matches(|c: char| {
50 c.is_ascii_digit()
51 || c == ' '
52 || matches!(
53 c,
54 '一' | '二' | '三' | '四' | '五' | '六' | '七' | '八' | '九' | '十'
55 )
56 });
57 rest.strip_prefix('步')
59 .map(|r| r.trim_start_matches([':', ':', '、', '.', ')', ' ']))
60 .unwrap_or(text)
61 } else {
62 text
63 };
64
65 let text = text.trim_start_matches(|c: char| {
67 c.is_ascii_digit() || matches!(c, '.' | ')' | '(' | '、' | ' ' | '-' | '/')
68 });
69
70 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 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 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 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 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 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 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 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 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 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 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 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 let args = UpdatePlanArgs {
328 objective: Some("安装 Docker".into()),
329 explanation: None,
330 plan: vec![],
331 };
332 assert!(args.validate().is_err());
333
334 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 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 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 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 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 assert_eq!(normalize_step_text("第一个任务:安装"), "第一个任务:安装");
388
389 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 assert_eq!(normalize_step_text("安装 Docker 引擎"), "安装 Docker 引擎");
397
398 assert_eq!(normalize_step_text(" 安装 Docker "), "安装 Docker");
400
401 let result = normalize_step_text("123");
403 assert!(!result.is_empty());
404 }
405
406 #[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}