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::{
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
227/// Public plugin factory. Callers that want this plugin installed
228/// (`lash-cli` under `profile.interactive_extras`) push an instance
229/// onto the plugin factory list. In non-root sessions the factory
230/// returns an inert plugin that registers nothing.
231pub struct UpdatePlanPluginFactory;
232
233impl UpdatePlanPluginFactory {
234    pub fn new() -> Self {
235        Self
236    }
237}
238
239impl Default for UpdatePlanPluginFactory {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl PluginFactory for UpdatePlanPluginFactory {
246    fn id(&self) -> &'static str {
247        PLUGIN_ID
248    }
249
250    fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
251        Ok(Arc::new(UpdatePlanPlugin {
252            active: ctx.is_root_session(),
253            state: Arc::new(Mutex::new(PlanState::default())),
254        }))
255    }
256}
257
258struct UpdatePlanPlugin {
259    active: bool,
260    state: Arc<Mutex<PlanState>>,
261}
262
263impl SessionPlugin for UpdatePlanPlugin {
264    fn id(&self) -> &'static str {
265        PLUGIN_ID
266    }
267
268    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
269        if !self.active {
270            return Ok(());
271        }
272        reg.prompt().contribute(Arc::new(|_ctx| {
273            Box::pin(async move { Ok(planning_prompt_contributions()) })
274        }));
275        reg.tools()
276            .provider(Arc::new(update_plan_provider(Arc::clone(&self.state))))?;
277        let after_state = Arc::clone(&self.state);
278        reg.tool_calls().after(Arc::new(move |ctx| {
279            let state = Arc::clone(&after_state);
280            Box::pin(async move {
281                if ctx.tool_name != "update_plan" {
282                    return Ok(Vec::new());
283                }
284                if !ctx.result.is_success() {
285                    tracing::debug!(
286                        target: "lash_core::update_plan",
287                        "after_tool_call observed failed update_plan; skipping emit",
288                    );
289                    return Ok(Vec::new());
290                }
291                let snapshot = state
292                    .lock()
293                    .map_err(|_| PluginError::Session("update_plan state poisoned".to_string()))?
294                    .snapshot();
295                tracing::info!(
296                    target: "lash_core::update_plan",
297                    items = snapshot.plan.len(),
298                    generation = snapshot.generation,
299                    "emitting plan snapshot event",
300                );
301                Ok(vec![PluginDirective::emit_runtime_events(vec![
302                    plan_snapshot_event(&snapshot)?,
303                ])])
304            })
305        }));
306        Ok(())
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use lash_core::testing::{MockSessionManager, test_standard_protocol_factories};
314    use lash_core::{PluginHost, PromptHookContext, PromptSlot, SessionReadView, SessionSnapshot};
315
316    #[tokio::test]
317    async fn validates_shape() {
318        let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
319        let result = lash_core::testing::run_tool(
320            &tool,
321            "update_plan",
322            &json!({"plan":[{"step":"","status":"pending"}]}),
323        )
324        .await;
325        assert!(!result.is_success());
326    }
327
328    #[tokio::test]
329    async fn rejects_multiple_in_progress_steps() {
330        let tool = update_plan_provider(Arc::new(Mutex::new(PlanState::default())));
331        let result = lash_core::testing::run_tool(
332            &tool,
333            "update_plan",
334            &json!({
335                "plan":[
336                    {"step":"a","status":"in_progress"},
337                    {"step":"b","status":"in_progress"}
338                ]
339            }),
340        )
341        .await;
342        assert!(!result.is_success());
343    }
344
345    #[tokio::test]
346    async fn bumps_generation_on_success() {
347        let state = Arc::new(Mutex::new(PlanState::default()));
348        let tool = update_plan_provider(Arc::clone(&state));
349        assert_eq!(state.lock().unwrap().generation, 0);
350        let result = lash_core::testing::run_tool(
351            &tool,
352            "update_plan",
353            &json!({
354                "plan":[{"step":"one","status":"pending"}]
355            }),
356        )
357        .await;
358        assert!(result.is_success());
359        assert_eq!(state.lock().unwrap().generation, 1);
360    }
361
362    #[test]
363    fn plan_snapshot_event_encodes_snapshot() {
364        let snapshot = PlanSnapshot {
365            explanation: None,
366            plan: vec![
367                PlanItem {
368                    step: "done work".into(),
369                    status: "completed".into(),
370                },
371                PlanItem {
372                    step: "current".into(),
373                    status: "in_progress".into(),
374                },
375                PlanItem {
376                    step: "later".into(),
377                    status: "pending".into(),
378                },
379            ],
380            generation: 1,
381        };
382        let event = plan_snapshot_event(&snapshot).expect("event");
383        let lash_core::PluginRuntimeEvent::Custom { name, payload } = event else {
384            panic!("expected custom event");
385        };
386        assert_eq!(name, UPDATE_PLAN_SNAPSHOT_EVENT);
387        let decoded: PlanSnapshot = serde_json::from_value(payload).expect("snapshot payload");
388        assert_eq!(decoded, snapshot);
389    }
390
391    #[test]
392    fn factory_marks_child_sessions_inactive() {
393        let factory = UpdatePlanPluginFactory::new();
394        let root_ctx = PluginSessionContext {
395            session_id: "root".into(),
396            tool_access: lash_core::SessionToolAccess::default(),
397            subagent: None,
398            extensions: Default::default(),
399            plugin_options: Default::default(),
400            parent_session_id: None,
401        };
402        let child_ctx = PluginSessionContext {
403            session_id: "child".into(),
404            tool_access: lash_core::SessionToolAccess::default(),
405            subagent: None,
406            extensions: Default::default(),
407            plugin_options: Default::default(),
408            parent_session_id: Some("root".into()),
409        };
410        assert!(root_ctx.is_root_session());
411        assert!(!child_ctx.is_root_session());
412        factory.build(&root_ctx).expect("root build");
413        factory.build(&child_ctx).expect("child build");
414    }
415
416    #[tokio::test]
417    async fn root_session_contributes_planning_guidance() {
418        let mut factories = test_standard_protocol_factories();
419        factories.push(Arc::new(UpdatePlanPluginFactory::new()));
420        let plugin_host = PluginHost::new(factories);
421        let session = plugin_host.build_session("root", None).expect("session");
422
423        let contributions = session
424            .collect_prompt_contributions(PromptHookContext {
425                session_id: "root".to_string(),
426                sessions: Arc::new(MockSessionManager::default()),
427                state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
428                protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
429                turn_context: lash_core::TurnContext::default(),
430            })
431            .await
432            .expect("prompt contributions");
433
434        let contribution = contributions
435            .iter()
436            .find(|contribution| contribution.title.as_deref() == Some("Planning"))
437            .expect("planning guidance");
438        assert_eq!(contribution.slot, PromptSlot::Guidance);
439        assert_eq!(contribution.content.as_ref(), PLANNING_GUIDANCE);
440    }
441
442    #[tokio::test]
443    async fn child_session_does_not_contribute_planning_guidance() {
444        let mut factories = test_standard_protocol_factories();
445        factories.push(Arc::new(UpdatePlanPluginFactory::new()));
446        let plugin_host = PluginHost::new(factories);
447        let session = plugin_host
448            .build_session_with_parent(
449                "child",
450                Some("root".to_string()),
451                None,
452                lash_core::plugin::SessionAuthorityContext::default(),
453            )
454            .expect("session");
455
456        let contributions = session
457            .collect_prompt_contributions(PromptHookContext {
458                session_id: "child".to_string(),
459                sessions: Arc::new(MockSessionManager::default()),
460                state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
461                protocol_turn_options: lash_core::ProtocolTurnOptions::default(),
462                turn_context: lash_core::TurnContext::default(),
463            })
464            .await
465            .expect("prompt contributions");
466
467        assert!(
468            !contributions
469                .iter()
470                .any(|contribution| contribution.title.as_deref() == Some("Planning"))
471        );
472    }
473}