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            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            lashlang_abilities: Default::default(),
405            lashlang_language_features: 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}