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_lashlang_runtime::{LashlangToolBinding, ToolDefinitionLashlangExt};
30use lash_tool_support::{StaticToolExecute, StaticToolProvider};
31
32const PLUGIN_ID: &str = "update_plan";
33const UPDATE_PLAN_SNAPSHOT_EVENT: &str = "update_plan.snapshot";
34const PLANNING_GUIDANCE: &str = concat!(
35 "Use `plan.update` for substantial multi-step work and skip it for trivial or single-step asks. ",
36 "Write short steps and keep exactly one step `in_progress` while work is underway. ",
37 "Mark completed work before moving on, use `explanation` when the plan changes, and update the plan as soon as scope or sequencing shifts. ",
38 "Do not let the plan go stale while coding or running validation. ",
39 "After a `plan.update` call, briefly summarize what changed and what comes next instead of repeating the full checklist. ",
40 "Finish by marking every step `completed` when the task is done.",
41);
42
43#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct PlanItem {
45 pub step: String,
46 pub status: String,
47}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50pub struct PlanSnapshot {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub explanation: Option<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub plan: Vec<PlanItem>,
55 #[serde(default)]
56 pub generation: u64,
57}
58
59impl PlanSnapshot {
60 pub fn generation(&self) -> u64 {
61 self.generation
62 }
63}
64
65#[derive(Default)]
66struct PlanState {
67 explanation: Option<String>,
68 items: Vec<PlanItem>,
69 generation: u64,
70}
71
72impl PlanState {
73 fn snapshot(&self) -> PlanSnapshot {
74 PlanSnapshot {
75 explanation: self.explanation.clone(),
76 plan: self.items.clone(),
77 generation: self.generation,
78 }
79 }
80
81 fn apply(&mut self, explanation: Option<String>, items: Vec<PlanItem>) {
82 self.explanation = explanation;
83 self.items = items;
84 self.generation = self.generation.wrapping_add(1).max(1);
85 }
86}
87
88struct UpdatePlanTool {
89 state: Arc<Mutex<PlanState>>,
90}
91
92fn update_plan_provider(state: Arc<Mutex<PlanState>>) -> StaticToolProvider<UpdatePlanTool> {
93 StaticToolProvider::new(
94 vec![update_plan_tool_definition()],
95 UpdatePlanTool { state },
96 )
97}
98
99#[async_trait::async_trait]
100impl StaticToolExecute for UpdatePlanTool {
101 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
102 match call.name {
103 "update_plan" => execute_update_plan(&self.state, call.args),
104 other => ToolResult::err_fmt(format_args!("Unknown tool: {other}")),
105 }
106 }
107}
108
109fn update_plan_tool_definition() -> ToolDefinition {
110 ToolDefinition::raw(
111 "tool:update_plan",
112 "update_plan",
113 "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.",
114 serde_json::json!({
115 "type": "object",
116 "properties": {
117 "explanation": { "type": "string" },
118 "plan": {
119 "type": "array",
120 "items": {
121 "type": "object",
122 "properties": {
123 "step": { "type": "string" },
124 "status": {
125 "type": "string",
126 "enum": ["pending", "in_progress", "completed"]
127 }
128 },
129 "required": ["step", "status"],
130 "additionalProperties": false
131 }
132 }
133 },
134 "required": ["plan"],
135 "additionalProperties": false
136 }),
137 serde_json::json!({ "type": "string" }),
138 )
139 .with_examples(vec![
140 "{\"explanation\":\"I found the main renderer.\",\"plan\":[{\"step\":\"Inspect renderer\",\"status\":\"completed\"},{\"step\":\"Patch layout\",\"status\":\"in_progress\"},{\"step\":\"Run tests\",\"status\":\"pending\"}]}"
141 .into(),
142 ])
143 .with_lashlang_binding(LashlangToolBinding::new(["plan"], "update"))
144 .with_scheduling(ToolScheduling::Parallel)
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;
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}