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