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