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_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
226/// Public plugin factory. Callers that want this plugin installed
227/// (`lash-cli` under `profile.interactive_extras`) push an instance
228/// onto the plugin factory list. In non-root sessions the factory
229/// returns an inert plugin that registers nothing.
230pub 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}