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};
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}
146
147fn execute_update_plan(state: &Arc<Mutex<PlanState>>, args: &serde_json::Value) -> ToolResult {
148 let explanation = args
149 .get("explanation")
150 .and_then(|value| value.as_str())
151 .map(str::trim)
152 .filter(|value| !value.is_empty())
153 .map(str::to_string);
154 let Some(raw_plan) = args.get("plan").and_then(|value| value.as_array()) else {
155 return ToolResult::err_fmt("Missing required parameter: plan");
156 };
157 if raw_plan.is_empty() {
158 return ToolResult::err_fmt("Plan must contain at least one step");
159 }
160
161 let mut items = Vec::with_capacity(raw_plan.len());
162 for (idx, item) in raw_plan.iter().enumerate() {
163 let Some(object) = item.as_object() else {
164 return ToolResult::err_fmt(format_args!(
165 "Invalid plan[{idx}]: expected object with step and status"
166 ));
167 };
168 let Some(step) = object
169 .get("step")
170 .and_then(|value| value.as_str())
171 .map(str::trim)
172 .filter(|value| !value.is_empty())
173 else {
174 return ToolResult::err_fmt(format_args!(
175 "Invalid plan[{idx}].step: expected non-empty string"
176 ));
177 };
178 let Some(status) = object
179 .get("status")
180 .and_then(|value| value.as_str())
181 .map(str::trim)
182 else {
183 return ToolResult::err_fmt(format_args!(
184 "Invalid plan[{idx}].status: expected string"
185 ));
186 };
187 if !matches!(status, "pending" | "in_progress" | "completed") {
188 return ToolResult::err_fmt(format_args!(
189 "Invalid plan[{idx}].status: expected pending, in_progress, or completed"
190 ));
191 }
192 items.push(PlanItem {
193 step: step.to_string(),
194 status: status.to_string(),
195 });
196 }
197
198 let in_progress = items
199 .iter()
200 .filter(|item| item.status == "in_progress")
201 .count();
202 if in_progress > 1 {
203 return ToolResult::err_fmt("Plan may contain at most one in_progress step");
204 }
205
206 let mut guard = state.lock().unwrap();
207 guard.apply(explanation, items);
208 ToolResult::ok(json!("Plan updated"))
209}
210
211fn plan_snapshot_event(
212 snapshot: &PlanSnapshot,
213) -> Result<lash_core::PluginRuntimeEvent, PluginError> {
214 Ok(lash_core::PluginRuntimeEvent::Custom {
215 name: UPDATE_PLAN_SNAPSHOT_EVENT.to_string(),
216 payload: serde_json::to_value(snapshot).map_err(|err| {
217 PluginError::Session(format!("failed to encode plan snapshot: {err}"))
218 })?,
219 })
220}
221
222fn planning_prompt_contributions() -> Vec<PromptContribution> {
223 vec![PromptContribution::guidance("Planning", PLANNING_GUIDANCE)]
224}
225
226pub struct UpdatePlanPluginFactory;
230
231impl UpdatePlanPluginFactory {
232 pub fn new() -> Self {
233 Self
234 }
235}
236
237impl Default for UpdatePlanPluginFactory {
238 fn default() -> Self {
239 Self::new()
240 }
241}
242
243impl PluginFactory for UpdatePlanPluginFactory {
244 fn id(&self) -> &'static str {
245 PLUGIN_ID
246 }
247
248 fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
249 Ok(Arc::new(UpdatePlanPlugin {
250 active: ctx.is_root_session(),
251 state: Arc::new(Mutex::new(PlanState::default())),
252 }))
253 }
254}
255
256struct UpdatePlanPlugin {
257 active: bool,
258 state: Arc<Mutex<PlanState>>,
259}
260
261impl SessionPlugin for UpdatePlanPlugin {
262 fn id(&self) -> &'static str {
263 PLUGIN_ID
264 }
265
266 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
267 if !self.active {
268 return Ok(());
269 }
270 reg.prompt().contribute(Arc::new(|_ctx| {
271 Box::pin(async move { Ok(planning_prompt_contributions()) })
272 }));
273 reg.tools()
274 .provider(Arc::new(update_plan_provider(Arc::clone(&self.state))))?;
275 let after_state = Arc::clone(&self.state);
276 reg.tool_calls().after(Arc::new(move |ctx| {
277 let state = Arc::clone(&after_state);
278 Box::pin(async move {
279 if ctx.tool_name != "update_plan" {
280 return Ok(Vec::new());
281 }
282 if !ctx.result.is_success() {
283 tracing::debug!(
284 target: "lash_core::update_plan",
285 "after_tool_call observed failed update_plan; skipping emit",
286 );
287 return Ok(Vec::new());
288 }
289 let snapshot = state
290 .lock()
291 .map_err(|_| PluginError::Session("update_plan state poisoned".to_string()))?
292 .snapshot();
293 tracing::info!(
294 target: "lash_core::update_plan",
295 items = snapshot.plan.len(),
296 generation = snapshot.generation,
297 "emitting plan snapshot event",
298 );
299 Ok(vec![PluginDirective::emit_runtime_events(vec![
300 plan_snapshot_event(&snapshot)?,
301 ])])
302 })
303 }));
304 Ok(())
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use lash_core::testing::{MockSessionManager, test_standard_protocol_factories};
312 use lash_core::{PluginHost, PromptHookContext, PromptSlot, SessionReadView, SessionSnapshot};
313
314 #[tokio::test]
315 async fn validates_shape() {
316 let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
317 let result = lash_core::testing::run_tool(
318 &tool,
319 "update_plan",
320 &json!({"plan":[{"step":"","status":"pending"}]}),
321 )
322 .await;
323 assert!(!result.is_success());
324 }
325
326 #[tokio::test]
327 async fn rejects_multiple_in_progress_steps() {
328 let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
329 let result = lash_core::testing::run_tool(
330 &tool,
331 "update_plan",
332 &json!({
333 "plan":[
334 {"step":"a","status":"in_progress"},
335 {"step":"b","status":"in_progress"}
336 ]
337 }),
338 )
339 .await;
340 assert!(!result.is_success());
341 }
342
343 #[tokio::test]
344 async fn bumps_generation_on_success() {
345 let state = Arc::new(Mutex::new(PlanState::default()));
346 let tool = update_plan_provider(Arc::clone(&state));
347 assert_eq!(state.lock().unwrap().generation, 0);
348 let result = lash_core::testing::run_tool(
349 &tool,
350 "update_plan",
351 &json!({
352 "plan":[{"step":"one","status":"pending"}]
353 }),
354 )
355 .await;
356 assert!(result.is_success());
357 assert_eq!(state.lock().unwrap().generation, 1);
358 }
359
360 #[test]
361 fn plan_snapshot_event_encodes_snapshot() {
362 let snapshot = PlanSnapshot {
363 explanation: None,
364 plan: vec![
365 PlanItem {
366 step: "done work".into(),
367 status: "completed".into(),
368 },
369 PlanItem {
370 step: "current".into(),
371 status: "in_progress".into(),
372 },
373 PlanItem {
374 step: "later".into(),
375 status: "pending".into(),
376 },
377 ],
378 generation: 1,
379 };
380 let event = plan_snapshot_event(&snapshot).expect("event");
381 let lash_core::PluginRuntimeEvent::Custom { name, payload } = event else {
382 panic!("expected custom event");
383 };
384 assert_eq!(name, UPDATE_PLAN_SNAPSHOT_EVENT);
385 let decoded: PlanSnapshot = serde_json::from_value(payload).expect("snapshot payload");
386 assert_eq!(decoded, snapshot);
387 }
388
389 #[test]
390 fn factory_marks_child_sessions_inactive() {
391 let factory = UpdatePlanPluginFactory::new();
392 let root_ctx = PluginSessionContext {
393 session_id: "root".into(),
394 tool_access: lash_core::SessionToolAccess::default(),
395 subagent: None,
396 extensions: 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 extensions: Default::default(),
405 plugin_options: Default::default(),
406 parent_session_id: Some("root".into()),
407 };
408 assert!(root_ctx.is_root_session());
409 assert!(!child_ctx.is_root_session());
410 factory.build(&root_ctx).expect("root build");
411 factory.build(&child_ctx).expect("child build");
412 }
413
414 #[tokio::test]
415 async fn root_session_contributes_planning_guidance() {
416 let mut factories = test_standard_protocol_factories();
417 factories.push(Arc::new(UpdatePlanPluginFactory::new()));
418 let plugin_host = PluginHost::new(factories);
419 let session = plugin_host.build_session("root", None).expect("session");
420
421 let contributions = session
422 .collect_prompt_contributions(PromptHookContext {
423 session_id: "root".to_string(),
424 sessions: Arc::new(MockSessionManager::default()),
425 state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
426 protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
427 turn_context: lash_core::TurnContext::default(),
428 })
429 .await
430 .expect("prompt contributions");
431
432 let contribution = contributions
433 .iter()
434 .find(|contribution| contribution.title.as_deref() == Some("Planning"))
435 .expect("planning guidance");
436 assert_eq!(contribution.slot, PromptSlot::Guidance);
437 assert_eq!(contribution.content.as_ref(), PLANNING_GUIDANCE);
438 }
439
440 #[tokio::test]
441 async fn child_session_does_not_contribute_planning_guidance() {
442 let mut factories = test_standard_protocol_factories();
443 factories.push(Arc::new(UpdatePlanPluginFactory::new()));
444 let plugin_host = PluginHost::new(factories);
445 let session = plugin_host
446 .build_session_with_parent(
447 "child",
448 Some("root".to_string()),
449 None,
450 lash_core::plugin::SessionAuthorityContext::default(),
451 )
452 .expect("session");
453
454 let contributions = session
455 .collect_prompt_contributions(PromptHookContext {
456 session_id: "child".to_string(),
457 sessions: Arc::new(MockSessionManager::default()),
458 state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
459 protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
460 turn_context: lash_core::TurnContext::default(),
461 })
462 .await
463 .expect("prompt contributions");
464
465 assert!(
466 !contributions
467 .iter()
468 .any(|contribution| contribution.title.as_deref() == Some("Planning"))
469 );
470 }
471}