Skip to main content

lash_plugin_plan_mode/
update_plan.rs

1//! `update_plan` tool + plugin.
2//!
3//! A root-only, interactive-only tool that lets the model publish a
4//! checklist. Each call fully replaces the previously-published plan.
5//! The plugin:
6//!
7//! * exposes one tool, `update_plan`, with status values `pending` /
8//!   `in_progress` / `completed` (at most one `in_progress` at a time)
9//! * stores the latest snapshot on the plugin so it survives resume /
10//!   snapshot
11//! * emits a semantic `update_plan.snapshot` runtime event after every
12//!   successful call. CLI/TUI crates decide how to present that snapshot.
13//!
14//! Gating: the plugin's [`PluginFactory::build`] returns an inert
15//! `SessionPlugin` whenever the session has a parent (i.e. the session
16//! is a subagent, compaction child, or any other non-root session).
17//! Interactive-vs-batch gating is handled by the registration site in
18//! `crates/lash-cli/src/bootstrap.rs`.
19
20use 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
224/// Public plugin factory. Callers that want this plugin installed
225/// (`lash-cli` under `profile.interactive_extras`) push an instance
226/// onto the plugin factory list. In non-root sessions the factory
227/// returns an inert plugin that registers nothing.
228pub 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            parent_session_id: None,
398        };
399        let child_ctx = PluginSessionContext {
400            session_id: "child".into(),
401            tool_access: lash_core::SessionToolAccess::default(),
402            subagent: None,
403            lashlang_abilities: Default::default(),
404            lashlang_language_features: Default::default(),
405            parent_session_id: Some("root".into()),
406        };
407        assert!(root_ctx.is_root_session());
408        assert!(!child_ctx.is_root_session());
409        factory.build(&root_ctx).expect("root build");
410        factory.build(&child_ctx).expect("child build");
411    }
412
413    #[tokio::test]
414    async fn root_session_contributes_planning_guidance() {
415        let mut factories = test_standard_protocol_factories();
416        factories.push(Arc::new(UpdatePlanPluginFactory::new()));
417        let plugin_host = PluginHost::new(factories);
418        let session = plugin_host.build_session("root", None).expect("session");
419
420        let contributions = session
421            .collect_prompt_contributions(PromptHookContext {
422                session_id: "root".to_string(),
423                sessions: Arc::new(MockSessionManager::default()),
424                state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
425                protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
426                turn_context: lash_core::TurnContext::default(),
427            })
428            .await
429            .expect("prompt contributions");
430
431        let contribution = contributions
432            .iter()
433            .find(|contribution| contribution.title.as_deref() == Some("Planning"))
434            .expect("planning guidance");
435        assert_eq!(contribution.slot, PromptSlot::Guidance);
436        assert_eq!(contribution.content.as_ref(), PLANNING_GUIDANCE);
437    }
438
439    #[tokio::test]
440    async fn child_session_does_not_contribute_planning_guidance() {
441        let mut factories = test_standard_protocol_factories();
442        factories.push(Arc::new(UpdatePlanPluginFactory::new()));
443        let plugin_host = PluginHost::new(factories);
444        let session = plugin_host
445            .build_session_with_parent(
446                "child",
447                Some("root".to_string()),
448                None,
449                lash_core::plugin::SessionAuthorityContext::default(),
450            )
451            .expect("session");
452
453        let contributions = session
454            .collect_prompt_contributions(PromptHookContext {
455                session_id: "child".to_string(),
456                sessions: Arc::new(MockSessionManager::default()),
457                state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
458                protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
459                turn_context: lash_core::TurnContext::default(),
460            })
461            .await
462            .expect("prompt contributions");
463
464        assert!(
465            !contributions
466                .iter()
467                .any(|contribution| contribution.title.as_deref() == Some("Planning"))
468        );
469    }
470}