1use std::sync::{Arc, Mutex};
21
22use serde_json::json;
23
24use lash_core::plugin::{
25 PluginDirective, PluginError, PluginFactory, PluginRegistrar, PluginSessionContext,
26 SessionPlugin,
27};
28use lash_core::{PromptContribution, ToolCall, ToolDefinition, ToolResult, ToolScheduling};
29use lash_tool_support::{StaticToolExecute, StaticToolProvider};
30
31const PLUGIN_ID: &str = "update_plan";
32const UPDATE_PLAN_SNAPSHOT_EVENT: &str = "update_plan.snapshot";
33const PLANNING_GUIDANCE: &str = concat!(
34 "Use `update_plan` for substantial multi-step work and skip it for trivial or single-step asks. ",
35 "Write short steps and keep exactly one step `in_progress` while work is underway. ",
36 "Mark completed work before moving on, use `explanation` when the plan changes, and update the plan as soon as scope or sequencing shifts. ",
37 "Do not let the plan go stale while coding or running validation. ",
38 "After an `update_plan` call, briefly summarize what changed and what comes next instead of repeating the full checklist. ",
39 "Finish by marking every step `completed` when the task is done.",
40);
41
42#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
43pub struct PlanItem {
44 pub step: String,
45 pub status: String,
46}
47
48#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49pub struct PlanSnapshot {
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub explanation: Option<String>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub plan: Vec<PlanItem>,
54 #[serde(default)]
55 pub generation: u64,
56}
57
58impl PlanSnapshot {
59 pub fn generation(&self) -> u64 {
60 self.generation
61 }
62}
63
64#[derive(Default)]
65struct PlanState {
66 explanation: Option<String>,
67 items: Vec<PlanItem>,
68 generation: u64,
69}
70
71impl PlanState {
72 fn snapshot(&self) -> PlanSnapshot {
73 PlanSnapshot {
74 explanation: self.explanation.clone(),
75 plan: self.items.clone(),
76 generation: self.generation,
77 }
78 }
79
80 fn apply(&mut self, explanation: Option<String>, items: Vec<PlanItem>) {
81 self.explanation = explanation;
82 self.items = items;
83 self.generation = self.generation.wrapping_add(1).max(1);
84 }
85}
86
87struct UpdatePlanTool {
88 state: Arc<Mutex<PlanState>>,
89}
90
91fn update_plan_provider(state: Arc<Mutex<PlanState>>) -> StaticToolProvider<UpdatePlanTool> {
92 StaticToolProvider::new(
93 vec![update_plan_tool_definition()],
94 UpdatePlanTool { state },
95 )
96}
97
98#[async_trait::async_trait]
99impl StaticToolExecute for UpdatePlanTool {
100 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
101 match call.name {
102 "update_plan" => execute_update_plan(&self.state, call.args),
103 other => ToolResult::err_fmt(format_args!("Unknown tool: {other}")),
104 }
105 }
106}
107
108fn update_plan_tool_definition() -> ToolDefinition {
109 ToolDefinition::raw(
110 "tool:update_plan",
111 "update_plan",
112 "Publish or replace the current plan: a list of short ordered steps with statuses (pending, in_progress, completed), plus an optional explanation. At most one step can be in_progress at a time. Each call fully replaces the previous plan. Use this for substantial multi-step work to keep progress visible to the user. After updating, briefly summarize what changed and what comes next instead of repeating the full checklist.",
113 serde_json::json!({
114 "type": "object",
115 "properties": {
116 "explanation": { "type": "string" },
117 "plan": {
118 "type": "array",
119 "items": {
120 "type": "object",
121 "properties": {
122 "step": { "type": "string" },
123 "status": {
124 "type": "string",
125 "enum": ["pending", "in_progress", "completed"]
126 }
127 },
128 "required": ["step", "status"],
129 "additionalProperties": false
130 }
131 }
132 },
133 "required": ["plan"],
134 "additionalProperties": false
135 }),
136 serde_json::json!({ "type": "string" }),
137 )
138 .with_examples(vec![
139 "{\"explanation\":\"I found the main renderer.\",\"plan\":[{\"step\":\"Inspect renderer\",\"status\":\"completed\"},{\"step\":\"Patch layout\",\"status\":\"in_progress\"},{\"step\":\"Run tests\",\"status\":\"pending\"}]}"
140 .into(),
141 ])
142 .with_scheduling(ToolScheduling::Parallel)
143}
144
145fn execute_update_plan(state: &Arc<Mutex<PlanState>>, args: &serde_json::Value) -> ToolResult {
146 let explanation = args
147 .get("explanation")
148 .and_then(|value| value.as_str())
149 .map(str::trim)
150 .filter(|value| !value.is_empty())
151 .map(str::to_string);
152 let Some(raw_plan) = args.get("plan").and_then(|value| value.as_array()) else {
153 return ToolResult::err_fmt("Missing required parameter: plan");
154 };
155 if raw_plan.is_empty() {
156 return ToolResult::err_fmt("Plan must contain at least one step");
157 }
158
159 let mut items = Vec::with_capacity(raw_plan.len());
160 for (idx, item) in raw_plan.iter().enumerate() {
161 let Some(object) = item.as_object() else {
162 return ToolResult::err_fmt(format_args!(
163 "Invalid plan[{idx}]: expected object with step and status"
164 ));
165 };
166 let Some(step) = object
167 .get("step")
168 .and_then(|value| value.as_str())
169 .map(str::trim)
170 .filter(|value| !value.is_empty())
171 else {
172 return ToolResult::err_fmt(format_args!(
173 "Invalid plan[{idx}].step: expected non-empty string"
174 ));
175 };
176 let Some(status) = object
177 .get("status")
178 .and_then(|value| value.as_str())
179 .map(str::trim)
180 else {
181 return ToolResult::err_fmt(format_args!(
182 "Invalid plan[{idx}].status: expected string"
183 ));
184 };
185 if !matches!(status, "pending" | "in_progress" | "completed") {
186 return ToolResult::err_fmt(format_args!(
187 "Invalid plan[{idx}].status: expected pending, in_progress, or completed"
188 ));
189 }
190 items.push(PlanItem {
191 step: step.to_string(),
192 status: status.to_string(),
193 });
194 }
195
196 let in_progress = items
197 .iter()
198 .filter(|item| item.status == "in_progress")
199 .count();
200 if in_progress > 1 {
201 return ToolResult::err_fmt("Plan may contain at most one in_progress step");
202 }
203
204 let mut guard = state.lock().unwrap();
205 guard.apply(explanation, items);
206 ToolResult::ok(json!("Plan updated"))
207}
208
209fn plan_snapshot_event(
210 snapshot: &PlanSnapshot,
211) -> Result<lash_core::PluginRuntimeEvent, PluginError> {
212 Ok(lash_core::PluginRuntimeEvent::Custom {
213 name: UPDATE_PLAN_SNAPSHOT_EVENT.to_string(),
214 payload: serde_json::to_value(snapshot).map_err(|err| {
215 PluginError::Session(format!("failed to encode plan snapshot: {err}"))
216 })?,
217 })
218}
219
220fn planning_prompt_contributions() -> Vec<PromptContribution> {
221 vec![PromptContribution::guidance("Planning", PLANNING_GUIDANCE)]
222}
223
224pub struct UpdatePlanPluginFactory;
229
230impl UpdatePlanPluginFactory {
231 pub fn new() -> Self {
232 Self
233 }
234}
235
236impl Default for UpdatePlanPluginFactory {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242impl PluginFactory for UpdatePlanPluginFactory {
243 fn id(&self) -> &'static str {
244 PLUGIN_ID
245 }
246
247 fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
248 Ok(Arc::new(UpdatePlanPlugin {
249 active: ctx.is_root_session(),
250 state: Arc::new(Mutex::new(PlanState::default())),
251 }))
252 }
253}
254
255struct UpdatePlanPlugin {
256 active: bool,
257 state: Arc<Mutex<PlanState>>,
258}
259
260impl SessionPlugin for UpdatePlanPlugin {
261 fn id(&self) -> &'static str {
262 PLUGIN_ID
263 }
264
265 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
266 if !self.active {
267 return Ok(());
268 }
269 reg.prompt().contribute(Arc::new(|_ctx| {
270 Box::pin(async move { Ok(planning_prompt_contributions()) })
271 }));
272 reg.tools()
273 .provider(Arc::new(update_plan_provider(Arc::clone(&self.state))))?;
274 let after_state = Arc::clone(&self.state);
275 reg.tool_calls().after(Arc::new(move |ctx| {
276 let state = Arc::clone(&after_state);
277 Box::pin(async move {
278 if ctx.tool_name != "update_plan" {
279 return Ok(Vec::new());
280 }
281 if !ctx.result.is_success() {
282 tracing::debug!(
283 target: "lash_core::update_plan",
284 "after_tool_call observed failed update_plan; skipping emit",
285 );
286 return Ok(Vec::new());
287 }
288 let snapshot = state
289 .lock()
290 .map_err(|_| PluginError::Session("update_plan state poisoned".to_string()))?
291 .snapshot();
292 tracing::info!(
293 target: "lash_core::update_plan",
294 items = snapshot.plan.len(),
295 generation = snapshot.generation,
296 "emitting plan snapshot event",
297 );
298 Ok(vec![PluginDirective::emit_runtime_events(vec![
299 plan_snapshot_event(&snapshot)?,
300 ])])
301 })
302 }));
303 Ok(())
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use lash_core::testing::{MockSessionManager, test_standard_protocol_factories};
311 use lash_core::{PluginHost, PromptHookContext, PromptSlot, SessionReadView, SessionSnapshot};
312
313 #[tokio::test]
314 async fn validates_shape() {
315 let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
316 let result = lash_core::testing::run_tool(
317 &tool,
318 "update_plan",
319 &json!({"plan":[{"step":"","status":"pending"}]}),
320 )
321 .await;
322 assert!(!result.is_success());
323 }
324
325 #[tokio::test]
326 async fn rejects_multiple_in_progress_steps() {
327 let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
328 let result = lash_core::testing::run_tool(
329 &tool,
330 "update_plan",
331 &json!({
332 "plan":[
333 {"step":"a","status":"in_progress"},
334 {"step":"b","status":"in_progress"}
335 ]
336 }),
337 )
338 .await;
339 assert!(!result.is_success());
340 }
341
342 #[tokio::test]
343 async fn bumps_generation_on_success() {
344 let state = Arc::new(Mutex::new(PlanState::default()));
345 let tool = update_plan_provider(Arc::clone(&state));
346 assert_eq!(state.lock().unwrap().generation, 0);
347 let result = lash_core::testing::run_tool(
348 &tool,
349 "update_plan",
350 &json!({
351 "plan":[{"step":"one","status":"pending"}]
352 }),
353 )
354 .await;
355 assert!(result.is_success());
356 assert_eq!(state.lock().unwrap().generation, 1);
357 }
358
359 #[test]
360 fn plan_snapshot_event_encodes_snapshot() {
361 let snapshot = PlanSnapshot {
362 explanation: None,
363 plan: vec![
364 PlanItem {
365 step: "done work".into(),
366 status: "completed".into(),
367 },
368 PlanItem {
369 step: "current".into(),
370 status: "in_progress".into(),
371 },
372 PlanItem {
373 step: "later".into(),
374 status: "pending".into(),
375 },
376 ],
377 generation: 1,
378 };
379 let event = plan_snapshot_event(&snapshot).expect("event");
380 let lash_core::PluginRuntimeEvent::Custom { name, payload } = event else {
381 panic!("expected custom event");
382 };
383 assert_eq!(name, UPDATE_PLAN_SNAPSHOT_EVENT);
384 let decoded: PlanSnapshot = serde_json::from_value(payload).expect("snapshot payload");
385 assert_eq!(decoded, snapshot);
386 }
387
388 #[test]
389 fn factory_marks_child_sessions_inactive() {
390 let factory = UpdatePlanPluginFactory::new();
391 let root_ctx = PluginSessionContext {
392 session_id: "root".into(),
393 tool_access: lash_core::SessionToolAccess::default(),
394 subagent: None,
395 lashlang_abilities: Default::default(),
396 lashlang_language_features: Default::default(),
397 plugin_options: Default::default(),
398 parent_session_id: None,
399 };
400 let child_ctx = PluginSessionContext {
401 session_id: "child".into(),
402 tool_access: lash_core::SessionToolAccess::default(),
403 subagent: None,
404 lashlang_abilities: Default::default(),
405 lashlang_language_features: Default::default(),
406 plugin_options: Default::default(),
407 parent_session_id: Some("root".into()),
408 };
409 assert!(root_ctx.is_root_session());
410 assert!(!child_ctx.is_root_session());
411 factory.build(&root_ctx).expect("root build");
412 factory.build(&child_ctx).expect("child build");
413 }
414
415 #[tokio::test]
416 async fn root_session_contributes_planning_guidance() {
417 let mut factories = test_standard_protocol_factories();
418 factories.push(Arc::new(UpdatePlanPluginFactory::new()));
419 let plugin_host = PluginHost::new(factories);
420 let session = plugin_host.build_session("root", None).expect("session");
421
422 let contributions = session
423 .collect_prompt_contributions(PromptHookContext {
424 session_id: "root".to_string(),
425 sessions: Arc::new(MockSessionManager::default()),
426 state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
427 protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
428 turn_context: lash_core::TurnContext::default(),
429 })
430 .await
431 .expect("prompt contributions");
432
433 let contribution = contributions
434 .iter()
435 .find(|contribution| contribution.title.as_deref() == Some("Planning"))
436 .expect("planning guidance");
437 assert_eq!(contribution.slot, PromptSlot::Guidance);
438 assert_eq!(contribution.content.as_ref(), PLANNING_GUIDANCE);
439 }
440
441 #[tokio::test]
442 async fn child_session_does_not_contribute_planning_guidance() {
443 let mut factories = test_standard_protocol_factories();
444 factories.push(Arc::new(UpdatePlanPluginFactory::new()));
445 let plugin_host = PluginHost::new(factories);
446 let session = plugin_host
447 .build_session_with_parent(
448 "child",
449 Some("root".to_string()),
450 None,
451 lash_core::plugin::SessionAuthorityContext::default(),
452 )
453 .expect("session");
454
455 let contributions = session
456 .collect_prompt_contributions(PromptHookContext {
457 session_id: "child".to_string(),
458 sessions: Arc::new(MockSessionManager::default()),
459 state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
460 protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
461 turn_context: lash_core::TurnContext::default(),
462 })
463 .await
464 .expect("prompt contributions");
465
466 assert!(
467 !contributions
468 .iter()
469 .any(|contribution| contribution.title.as_deref() == Some("Planning"))
470 );
471 }
472}