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;
231
232impl UpdatePlanPluginFactory {
233 pub fn new() -> Self {
234 Self
235 }
236}
237
238impl Default for UpdatePlanPluginFactory {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244impl PluginFactory for UpdatePlanPluginFactory {
245 fn id(&self) -> &'static str {
246 PLUGIN_ID
247 }
248
249 fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
250 Ok(Arc::new(UpdatePlanPlugin {
251 active: ctx.is_root_session(),
252 state: Arc::new(Mutex::new(PlanState::default())),
253 }))
254 }
255}
256
257struct UpdatePlanPlugin {
258 active: bool,
259 state: Arc<Mutex<PlanState>>,
260}
261
262impl SessionPlugin for UpdatePlanPlugin {
263 fn id(&self) -> &'static str {
264 PLUGIN_ID
265 }
266
267 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
268 if !self.active {
269 return Ok(());
270 }
271 reg.prompt().contribute(Arc::new(|_ctx| {
272 Box::pin(async move { Ok(planning_prompt_contributions()) })
273 }));
274 reg.tools()
275 .provider(Arc::new(update_plan_provider(Arc::clone(&self.state))))?;
276 let after_state = Arc::clone(&self.state);
277 reg.tool_calls().after(Arc::new(move |ctx| {
278 let state = Arc::clone(&after_state);
279 Box::pin(async move {
280 if ctx.tool_name != "update_plan" {
281 return Ok(Vec::new());
282 }
283 if !ctx.result.is_success() {
284 tracing::debug!(
285 target: "lash_core::update_plan",
286 "after_tool_call observed failed update_plan; skipping emit",
287 );
288 return Ok(Vec::new());
289 }
290 let snapshot = state
291 .lock()
292 .map_err(|_| PluginError::Session("update_plan state poisoned".to_string()))?
293 .snapshot();
294 tracing::info!(
295 target: "lash_core::update_plan",
296 items = snapshot.plan.len(),
297 generation = snapshot.generation,
298 "emitting plan snapshot event",
299 );
300 Ok(vec![PluginDirective::emit_runtime_events(vec![
301 plan_snapshot_event(&snapshot)?,
302 ])])
303 })
304 }));
305 Ok(())
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use lash_core::testing::{MockSessionManager, test_standard_protocol_factories};
313 use lash_core::{PluginHost, PromptHookContext, PromptSlot, SessionReadView, SessionSnapshot};
314
315 #[tokio::test]
316 async fn validates_shape() {
317 let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
318 let result = lash_core::testing::run_tool(
319 &tool,
320 "update_plan",
321 &json!({"plan":[{"step":"","status":"pending"}]}),
322 )
323 .await;
324 assert!(!result.is_success());
325 }
326
327 #[tokio::test]
328 async fn rejects_multiple_in_progress_steps() {
329 let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
330 let result = lash_core::testing::run_tool(
331 &tool,
332 "update_plan",
333 &json!({
334 "plan":[
335 {"step":"a","status":"in_progress"},
336 {"step":"b","status":"in_progress"}
337 ]
338 }),
339 )
340 .await;
341 assert!(!result.is_success());
342 }
343
344 #[tokio::test]
345 async fn bumps_generation_on_success() {
346 let state = Arc::new(Mutex::new(PlanState::default()));
347 let tool = update_plan_provider(Arc::clone(&state));
348 assert_eq!(state.lock().unwrap().generation, 0);
349 let result = lash_core::testing::run_tool(
350 &tool,
351 "update_plan",
352 &json!({
353 "plan":[{"step":"one","status":"pending"}]
354 }),
355 )
356 .await;
357 assert!(result.is_success());
358 assert_eq!(state.lock().unwrap().generation, 1);
359 }
360
361 #[test]
362 fn plan_snapshot_event_encodes_snapshot() {
363 let snapshot = PlanSnapshot {
364 explanation: None,
365 plan: vec![
366 PlanItem {
367 step: "done work".into(),
368 status: "completed".into(),
369 },
370 PlanItem {
371 step: "current".into(),
372 status: "in_progress".into(),
373 },
374 PlanItem {
375 step: "later".into(),
376 status: "pending".into(),
377 },
378 ],
379 generation: 1,
380 };
381 let event = plan_snapshot_event(&snapshot).expect("event");
382 let lash_core::PluginRuntimeEvent::Custom { name, payload } = event else {
383 panic!("expected custom event");
384 };
385 assert_eq!(name, UPDATE_PLAN_SNAPSHOT_EVENT);
386 let decoded: PlanSnapshot = serde_json::from_value(payload).expect("snapshot payload");
387 assert_eq!(decoded, snapshot);
388 }
389
390 #[test]
391 fn factory_marks_child_sessions_inactive() {
392 let factory = UpdatePlanPluginFactory::new();
393 let root_ctx = PluginSessionContext {
394 session_id: "root".into(),
395 tool_access: lash_core::SessionToolAccess::default(),
396 subagent: None,
397 extensions: Default::default(),
398 plugin_options: Default::default(),
399 parent_session_id: None,
400 };
401 let child_ctx = PluginSessionContext {
402 session_id: "child".into(),
403 tool_access: lash_core::SessionToolAccess::default(),
404 subagent: None,
405 extensions: 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}