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 Use this to show the user what steps you plan to take and update step statuses as you go.\n\n\
124 Rules:\n\
125 - Always include the user's goal as `objective`\n\
126 - Plan must have at least one step\n\
127 - At most one step may be in_progress at a time\n\
128 - Step descriptions should be concise and human-readable\n\
129 - Call this again whenever step statuses change\n\
130 - Skip this for simple/trivial tasks\n\n\
131 When creating a plan, follow these principles:\n\
132 1. 探查先行 — 第一步先确认相关组件和依赖是否就绪\n\
133 2. 依赖排序 — 被依赖的先执行,独立步骤可并行但不强制\n\
134 3. 每步闭环 — 一步做完可独立验证结果,不等下步才知道成败\n\
135 4. 标注风险 — 涉及 rm、kill、restart、改配置文件时注明\n\
136 5. 粒度适中 — 不过细也不过大,每步是独立可验证的最小逻辑单元\n\
137 6. 收敛止步 — 步骤过多说明任务需要拆分或先讨论再定",
138 )
139 }
140
141 fn schema(&self) -> Value {
142 json!({
143 "type": "object",
144 "properties": {
145 "objective": {
146 "type": "string",
147 "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."
148 },
149 "explanation": {
150 "type": "string",
151 "description": "Optional explanation of why the plan is being created or changed."
152 },
153 "plan": {
154 "type": "array",
155 "description": "The complete plan checklist. Each item has a step description and status.",
156 "items": {
157 "type": "object",
158 "properties": {
159 "step": {
160 "type": "string",
161 "description": "Short description of this step (5-7 words). Example: '安装 Docker 引擎'"
162 },
163 "status": {
164 "type": "string",
165 "enum": ["pending", "in_progress", "completed"],
166 "description": "Current status of this step."
167 }
168 },
169 "required": ["step", "status"],
170 "additionalProperties": false
171 }
172 }
173 },
174 "required": ["plan"],
175 "additionalProperties": false
176 })
177 }
178
179 fn metadata(&self) -> crate::tool::ToolMetadata {
180 crate::tool::ToolMetadata {
181 name: self.name().to_string(),
182 description: "Create or update a task plan to show the user a checklist with progress."
183 .to_string(),
184 origin: "agent-base".to_string(),
185 version: env!("CARGO_PKG_VERSION").to_string(),
186 requirements: vec![],
187 }
188 }
189
190 async fn call(&self, args: &Value, ctx: &ToolContext) -> AgentResult<Vec<Content>> {
191 let plan_args: UpdatePlanArgs = serde_json::from_value(args.clone()).map_err(|e| {
192 crate::types::AgentError::ToolArgsInvalid {
193 name: "update_plan".to_string(),
194 raw: format!("deserialization error: {e}"),
195 }
196 })?;
197
198 if let Err(validation_err) = plan_args.validate() {
200 return Err(crate::types::AgentError::ToolArgsInvalid {
201 name: "update_plan".to_string(),
202 raw: validation_err,
203 });
204 }
205
206 let objective = match plan_args.objective {
208 Some(ref obj) => {
209 *self.last_objective.lock().unwrap() = Some(obj.clone());
210 obj.clone()
211 }
212 None => self
213 .last_objective
214 .lock()
215 .unwrap()
216 .clone()
217 .unwrap_or_else(|| "(no objective specified)".to_string()),
218 };
219
220 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 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 let mut summary = format!("📋 {}: {}/{} steps completed", objective, completed, total);
244 if in_progress > 0 {
245 let current = normalized_plan
246 .iter()
247 .find(|item| item.status == crate::types::PlanStepStatus::InProgress);
248 if let Some(item) = current {
249 write!(summary, ". Current: \"{}\"", item.step).unwrap();
250 }
251 }
252 if total == completed {
253 summary = format!("📋 {} — all steps completed!", objective);
254 }
255
256 ctx.event_bus.emit(RuntimeEvent::PlanUpdated {
258 session_id: ctx.session_id.clone(),
259 objective: objective.clone(),
260 explanation: plan_args.explanation.clone(),
261 plan: normalized_plan,
262 agent_id: None,
263 trace_id: None,
264 });
265
266 Ok(vec![Content::text(summary)])
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::tool::content_text;
274 use crate::types::{AgentError, PlanItem, PlanStepStatus};
275
276 #[test]
277 fn test_update_plan_args_validation() {
278 let args = UpdatePlanArgs {
280 objective: Some("安装 Docker".into()),
281 explanation: None,
282 plan: vec![
283 PlanItem {
284 step: "Step 1".into(),
285 status: PlanStepStatus::Completed,
286 },
287 PlanItem {
288 step: "Step 2".into(),
289 status: PlanStepStatus::InProgress,
290 },
291 PlanItem {
292 step: "Step 3".into(),
293 status: PlanStepStatus::Pending,
294 },
295 ],
296 };
297 assert!(args.validate().is_ok());
298
299 let args = UpdatePlanArgs {
301 objective: None,
302 explanation: None,
303 plan: vec![PlanItem {
304 step: "Step 1".into(),
305 status: PlanStepStatus::Pending,
306 }],
307 };
308 assert!(args.validate().is_ok());
309
310 let args = UpdatePlanArgs {
312 objective: Some("".into()),
313 explanation: None,
314 plan: vec![PlanItem {
315 step: "Step 1".into(),
316 status: PlanStepStatus::Pending,
317 }],
318 };
319 assert!(args.validate().is_err());
320
321 let args = UpdatePlanArgs {
323 objective: Some("安装 Docker".into()),
324 explanation: None,
325 plan: vec![],
326 };
327 assert!(args.validate().is_err());
328
329 let args = UpdatePlanArgs {
331 objective: Some("安装 Docker".into()),
332 explanation: None,
333 plan: vec![
334 PlanItem {
335 step: "Step 1".into(),
336 status: PlanStepStatus::InProgress,
337 },
338 PlanItem {
339 step: "Step 2".into(),
340 status: PlanStepStatus::InProgress,
341 },
342 ],
343 };
344 assert!(args.validate().is_err());
345
346 let args = UpdatePlanArgs {
348 objective: Some("安装 Docker".into()),
349 explanation: None,
350 plan: vec![PlanItem {
351 step: " ".into(),
352 status: PlanStepStatus::Pending,
353 }],
354 };
355 assert!(args.validate().is_err());
356 }
357
358 #[test]
359 fn test_normalize_step_text() {
360 assert_eq!(normalize_step_text("1. 安装 Docker"), "安装 Docker");
362 assert_eq!(normalize_step_text("2) 添加 GPG 密钥"), "添加 GPG 密钥");
363 assert_eq!(
364 normalize_step_text("(3) 更新 APT 包列表"),
365 "更新 APT 包列表"
366 );
367 assert_eq!(normalize_step_text("Step 1: 安装 Docker"), "安装 Docker");
368 assert_eq!(normalize_step_text("step 2: 更新包列表"), "更新包列表");
369 assert_eq!(normalize_step_text("1、配置仓库"), "配置仓库");
370
371 assert_eq!(normalize_step_text("1-2) Install Docker"), "Install Docker");
373 assert_eq!(normalize_step_text("3/5) Verify config"), "Verify config");
374
375 assert_eq!(normalize_step_text("第一步:安装 Docker"), "安装 Docker");
377 assert_eq!(normalize_step_text("第1步:添加 GPG 密钥"), "添加 GPG 密钥");
378 assert_eq!(normalize_step_text("第 3 步: 更新包列表"), "更新包列表");
379 assert_eq!(normalize_step_text("第二步、配置仓库"), "配置仓库");
380
381 assert_eq!(normalize_step_text("第一个任务:安装"), "第一个任务:安装");
383
384 let long = "使用 apt install -y docker-ce docker-ce-cli containerd.io 命令来安装 Docker 引擎以及相关组件";
386 let result = normalize_step_text(long);
387 assert!(result.chars().count() <= 60);
388 assert!(result.ends_with("..."));
389
390 assert_eq!(normalize_step_text("安装 Docker 引擎"), "安装 Docker 引擎");
392
393 assert_eq!(normalize_step_text(" 安装 Docker "), "安装 Docker");
395
396 let result = normalize_step_text("123");
398 assert!(!result.is_empty());
399 }
400
401 #[test]
404 fn accessors_name_schema_metadata() {
405 let t = UpdatePlanTool::new();
406 assert_eq!(t.name(), "update_plan");
407 assert_eq!(t.schema()["required"], json!(["plan"]));
408 let m = t.metadata();
409 assert_eq!(m.name, "update_plan");
410 assert_eq!(m.origin, "agent-base");
411 assert!(m.requirements.is_empty());
412 }
413
414 #[test]
415 fn with_description_overrides_default() {
416 let t = UpdatePlanTool::new().with_description("custom desc".to_string());
417 assert_eq!(t.description(), "custom desc");
418
419 let t = UpdatePlanTool::new();
420 assert!(
421 t.description()
422 .contains("Record and display a structured plan")
423 );
424 }
425
426 #[tokio::test]
427 async fn call_with_full_plan_builds_summary_with_current() {
428 let t = UpdatePlanTool::new();
429 let ctx = ToolContext::for_test();
430 let args = json!({
431 "objective": "Install Docker",
432 "plan": [
433 {"step": "Install Docker", "status": "completed"},
434 {"step": "Add GPG key", "status": "in_progress"},
435 {"step": "Update packages", "status": "pending"}
436 ]
437 });
438 let out = t.call(&args, &ctx).await.unwrap();
439 let text = content_text(&out);
440 assert!(
441 text.contains("Install Docker: 1/3 steps completed"),
442 "{text}"
443 );
444 assert!(text.contains("Current: \"Add GPG key\""), "{text}");
445 }
446
447 #[tokio::test]
448 async fn call_all_completed_emits_completion() {
449 let t = UpdatePlanTool::new();
450 let ctx = ToolContext::for_test();
451 let args = json!({
452 "objective": "Install Docker",
453 "plan": [{"step": "Install Docker", "status": "completed"}]
454 });
455 let out = t.call(&args, &ctx).await.unwrap();
456 assert!(content_text(&out).contains("all steps completed"));
457 }
458
459 #[tokio::test]
460 async fn call_remembers_last_objective() {
461 let t = UpdatePlanTool::new();
462 let ctx = ToolContext::for_test();
463
464 let args = json!({
465 "objective": "Install Docker",
466 "plan": [{"step": "Install Docker", "status": "completed"}]
467 });
468 let _ = t.call(&args, &ctx).await.unwrap();
469
470 let args = json!({
471 "plan": [{"step": "Add GPG key", "status": "in_progress"}]
472 });
473 let out = t.call(&args, &ctx).await.unwrap();
474 assert!(content_text(&out).contains("Install Docker"));
475 }
476
477 #[tokio::test]
478 async fn call_without_objective_uses_placeholder() {
479 let t = UpdatePlanTool::new();
480 let ctx = ToolContext::for_test();
481 let args = json!({
482 "plan": [{"step": "Install Docker", "status": "completed"}]
483 });
484 let out = t.call(&args, &ctx).await.unwrap();
485 assert!(content_text(&out).contains("(no objective specified)"));
486 }
487
488 #[tokio::test]
489 async fn call_empty_plan_is_invalid() {
490 let t = UpdatePlanTool::new();
491 let ctx = ToolContext::for_test();
492 let args = json!({"objective": "x", "plan": []});
493 let err = t.call(&args, &ctx).await.unwrap_err();
494 assert!(matches!(err, AgentError::ToolArgsInvalid { .. }));
495 }
496}