Skip to main content

agentd/context/
plan.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **context plan**: a small ordered checklist the model owns for one
3//! context — created by the preflight or by `plan.create`, advanced by
4//! `plan.update`, cleared by `plan.clear`, rendered into every prompt,
5//! auto-advanced when a bound run, subagent or task reaches a terminal state.
6//! Temporary by intent (it belongs to the conversation, never to memory) and
7//! durable by construction (it is part of the context record, so it is
8//! checkpointed and restored with everything else the conversation knows).
9
10use crate::state::now_ms;
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum ItemStatus {
17    #[default]
18    Pending,
19    InProgress,
20    Done,
21    Blocked,
22    Skipped,
23}
24
25impl ItemStatus {
26    pub fn parse(s: &str) -> Option<ItemStatus> {
27        Some(match s {
28            "pending" => ItemStatus::Pending,
29            "in_progress" => ItemStatus::InProgress,
30            "done" => ItemStatus::Done,
31            "blocked" => ItemStatus::Blocked,
32            "skipped" => ItemStatus::Skipped,
33            _ => return None,
34        })
35    }
36    pub fn as_str(self) -> &'static str {
37        match self {
38            ItemStatus::Pending => "pending",
39            ItemStatus::InProgress => "in_progress",
40            ItemStatus::Done => "done",
41            ItemStatus::Blocked => "blocked",
42            ItemStatus::Skipped => "skipped",
43        }
44    }
45    pub fn is_terminal(self) -> bool {
46        matches!(
47            self,
48            ItemStatus::Done | ItemStatus::Blocked | ItemStatus::Skipped
49        )
50    }
51}
52
53/// What an item is bound to: its status follows the bound thing's outcome.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum Binding {
57    Run { id: String },
58    Subagent { handle: String },
59    Task { id: String },
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct PlanItem {
64    pub id: u32,
65    pub title: String,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub detail: Option<String>,
68    #[serde(default)]
69    pub status: ItemStatus,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub note: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub bound: Option<Binding>,
74    #[serde(default)]
75    pub updated: u64,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Plan {
80    pub goal: String,
81    pub items: Vec<PlanItem>,
82    #[serde(default)]
83    pub next_id: u32,
84    #[serde(default)]
85    pub created: u64,
86    #[serde(default)]
87    pub updated: u64,
88}
89
90/// The default cap (`context.plan.max_items`).
91pub const DEFAULT_MAX_ITEMS: usize = 32;
92
93impl Plan {
94    /// `plan.create {goal, items: [{title, detail?}]}`.
95    pub fn create(goal: &str, items: &[Value], max_items: usize) -> Result<Plan, String> {
96        if goal.trim().is_empty() {
97            return Err("plan.create: goal must be non-empty".into());
98        }
99        if items.len() > max_items {
100            return Err(format!(
101                "plan.create: {} items exceed context.plan.max_items ({max_items})",
102                items.len()
103            ));
104        }
105        let mut p = Plan {
106            goal: goal.trim().to_string(),
107            items: Vec::new(),
108            next_id: 1,
109            created: now_ms(),
110            updated: now_ms(),
111        };
112        for it in items {
113            let (title, detail) = item_fields(it)?;
114            p.push(title, detail);
115        }
116        Ok(p)
117    }
118
119    fn push(&mut self, title: String, detail: Option<String>) -> u32 {
120        let id = self.next_id;
121        self.next_id += 1;
122        self.items.push(PlanItem {
123            id,
124            title,
125            detail,
126            status: ItemStatus::Pending,
127            note: None,
128            bound: None,
129            updated: now_ms(),
130        });
131        self.updated = now_ms();
132        id
133    }
134
135    /// `plan.update {item, status?, note?, bind?, insert?, reorder?, title?, detail?}`:
136    /// `item` addresses by id (number) or by exact title; `insert` = `{title,
137    /// detail?, after?: id}` adds a new item (no `item` needed); `reorder` =
138    /// `[ids…]` sets the order.
139    pub fn update(&mut self, args: &Value, max_items: usize) -> Result<(), String> {
140        let mut did = false;
141        if let Some(ins) = args.get("insert") {
142            if self.items.len() >= max_items {
143                return Err(format!(
144                    "plan.update: context.plan.max_items ({max_items}) reached"
145                ));
146            }
147            let (title, detail) = item_fields(ins)?;
148            let id = self.push(title, detail);
149            if let Some(after) = ins.get("after").and_then(Value::as_u64) {
150                let item = self.items.pop().expect("just pushed");
151                let pos = self
152                    .items
153                    .iter()
154                    .position(|i| i.id as u64 == after)
155                    .map(|p| p + 1)
156                    .unwrap_or(self.items.len());
157                self.items.insert(pos, item);
158            }
159            let _ = id;
160            did = true;
161        }
162        if let Some(order) = args.get("reorder").and_then(Value::as_array) {
163            let ids: Vec<u32> = order
164                .iter()
165                .filter_map(Value::as_u64)
166                .map(|x| x as u32)
167                .collect();
168            let mut new_items = Vec::with_capacity(self.items.len());
169            for id in &ids {
170                if let Some(pos) = self.items.iter().position(|i| i.id == *id) {
171                    new_items.push(self.items.remove(pos));
172                }
173            }
174            new_items.append(&mut self.items);
175            self.items = new_items;
176            did = true;
177        }
178        if let Some(item) = args.get("item") {
179            let idx = self
180                .find(item)
181                .ok_or_else(|| format!("plan.update: no such item {item}"))?;
182            let it = &mut self.items[idx];
183            if let Some(s) = args.get("status") {
184                let s = s.as_str().and_then(ItemStatus::parse).ok_or_else(|| {
185                    "plan.update: status must be pending|in_progress|done|blocked|skipped"
186                        .to_string()
187                })?;
188                it.status = s;
189            }
190            if let Some(n) = args.get("note").and_then(Value::as_str) {
191                it.note = if n.is_empty() {
192                    None
193                } else {
194                    Some(n.to_string())
195                };
196            }
197            if let Some(t) = args.get("title").and_then(Value::as_str)
198                && !t.trim().is_empty()
199            {
200                it.title = t.trim().to_string();
201            }
202            if let Some(d) = args.get("detail").and_then(Value::as_str) {
203                it.detail = if d.is_empty() {
204                    None
205                } else {
206                    Some(d.to_string())
207                };
208            }
209            if let Some(b) = args.get("bind") {
210                it.bound = Some(parse_binding(b)?);
211                if it.status == ItemStatus::Pending {
212                    it.status = ItemStatus::InProgress;
213                }
214            }
215            it.updated = now_ms();
216            did = true;
217        }
218        if !did {
219            return Err(
220                "plan.update: nothing to do (give item+status/note/bind, insert, or reorder)"
221                    .into(),
222            );
223        }
224        self.updated = now_ms();
225        Ok(())
226    }
227
228    fn find(&self, key: &Value) -> Option<usize> {
229        match key {
230            Value::Number(n) => n
231                .as_u64()
232                .and_then(|id| self.items.iter().position(|i| i.id as u64 == id)),
233            Value::String(s) => s
234                .parse::<u32>()
235                .ok()
236                .and_then(|id| self.items.iter().position(|i| i.id == id))
237                .or_else(|| self.items.iter().position(|i| i.title == *s)),
238            _ => None,
239        }
240    }
241
242    /// Auto-advance every item bound to `binding`: a terminal outcome marks
243    /// the item done (success) or blocked (failure), with the outcome kept as
244    /// the note. Items already in a terminal status are left alone, so a
245    /// replayed settle cannot un-finish work. Returns the ids advanced.
246    pub fn settle_binding(&mut self, binding: &Binding, ok: bool, note: Option<&str>) -> Vec<u32> {
247        let mut out = Vec::new();
248        for it in self
249            .items
250            .iter_mut()
251            .filter(|i| i.bound.as_ref() == Some(binding) && !i.status.is_terminal())
252        {
253            it.status = if ok {
254                ItemStatus::Done
255            } else {
256                ItemStatus::Blocked
257            };
258            if let Some(n) = note {
259                it.note = Some(n.to_string());
260            }
261            it.updated = now_ms();
262            out.push(it.id);
263        }
264        if !out.is_empty() {
265            self.updated = now_ms();
266        }
267        out
268    }
269
270    /// `"2/5 done"`-style progress.
271    pub fn progress(&self) -> String {
272        let done = self
273            .items
274            .iter()
275            .filter(|i| i.status == ItemStatus::Done)
276            .count();
277        format!("{done}/{} done", self.items.len())
278    }
279
280    pub fn is_complete(&self) -> bool {
281        !self.items.is_empty() && self.items.iter().all(|i| i.status.is_terminal())
282    }
283
284    /// The compact prompt block.
285    pub fn render(&self) -> String {
286        let mut out = format!("Plan ({}): {}\n", self.progress(), self.goal);
287        for it in &self.items {
288            let mark = match it.status {
289                ItemStatus::Pending => "[ ]",
290                ItemStatus::InProgress => "[~]",
291                ItemStatus::Done => "[x]",
292                ItemStatus::Blocked => "[!]",
293                ItemStatus::Skipped => "[-]",
294            };
295            out.push_str(&format!("{mark} {}. {}", it.id, it.title));
296            if let Some(d) = &it.detail {
297                out.push_str(&format!(" — {d}"));
298            }
299            if let Some(n) = &it.note {
300                out.push_str(&format!(" ({n})"));
301            }
302            if let Some(b) = &it.bound {
303                let b = match b {
304                    Binding::Run { id } => format!("run {id}"),
305                    Binding::Subagent { handle } => format!("subagent {handle}"),
306                    Binding::Task { id } => format!("task {id}"),
307                };
308                out.push_str(&format!(" [{b}]"));
309            }
310            out.push('\n');
311        }
312        out
313    }
314
315    pub fn to_value(&self) -> Value {
316        serde_json::to_value(self).unwrap_or(json!({}))
317    }
318}
319
320fn item_fields(v: &Value) -> Result<(String, Option<String>), String> {
321    let title = match v {
322        Value::String(s) => s.trim().to_string(),
323        Value::Object(_) => v
324            .get("title")
325            .and_then(Value::as_str)
326            .unwrap_or("")
327            .trim()
328            .to_string(),
329        _ => String::new(),
330    };
331    if title.is_empty() {
332        return Err("plan item needs a non-empty title".into());
333    }
334    let detail = v
335        .get("detail")
336        .and_then(Value::as_str)
337        .filter(|d| !d.is_empty())
338        .map(str::to_string);
339    Ok((title, detail))
340}
341
342fn parse_binding(v: &Value) -> Result<Binding, String> {
343    if let Some(id) = v.get("run").and_then(Value::as_str) {
344        return Ok(Binding::Run { id: id.to_string() });
345    }
346    if let Some(h) = v.get("subagent").and_then(Value::as_str) {
347        return Ok(Binding::Subagent {
348            handle: h.to_string(),
349        });
350    }
351    if let Some(id) = v.get("task").and_then(Value::as_str) {
352        return Ok(Binding::Task { id: id.to_string() });
353    }
354    Err("bind must be {run: id} | {subagent: handle} | {task: id}".into())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn create_update_bind_settle_and_render() {
363        let mut p = Plan::create(
364            "ship v2",
365            &[
366                json!("write code"),
367                json!({"title": "test", "detail": "all suites"}),
368            ],
369            32,
370        )
371        .unwrap();
372        assert_eq!(p.items.len(), 2);
373        assert_eq!(p.progress(), "0/2 done");
374        p.update(
375            &json!({"item": 1, "status": "in_progress", "note": "started"}),
376            32,
377        )
378        .unwrap();
379        p.update(&json!({"item": "test", "bind": {"run": "r-1"}}), 32)
380            .unwrap();
381        assert_eq!(
382            p.items[1].status,
383            ItemStatus::InProgress,
384            "binding moves pending → in_progress"
385        );
386        p.update(&json!({"insert": {"title": "docs", "after": 1}}), 32)
387            .unwrap();
388        assert_eq!(
389            p.items.iter().map(|i| i.title.as_str()).collect::<Vec<_>>(),
390            vec!["write code", "docs", "test"]
391        );
392        p.update(&json!({"reorder": [3, 1]}), 32).unwrap();
393        assert_eq!(
394            p.items.iter().map(|i| i.id).collect::<Vec<_>>(),
395            vec![3, 1, 2]
396        );
397        assert_eq!(
398            p.settle_binding(&Binding::Run { id: "r-1".into() }, true, Some("completed")),
399            vec![2]
400        );
401        assert_eq!(
402            p.items.iter().find(|i| i.id == 2).unwrap().status,
403            ItemStatus::Done
404        );
405        let r = p.render();
406        assert!(r.starts_with("Plan (1/3 done): ship v2"), "{r}");
407        assert!(r.contains("[~] 1. write code (started)"), "{r}");
408        assert!(
409            r.contains("[x] 2. test — all suites (completed) [run r-1]"),
410            "{r}"
411        );
412        assert!(!p.is_complete());
413        // Errors.
414        assert!(
415            p.update(&json!({"item": 99, "status": "done"}), 32)
416                .is_err()
417        );
418        assert!(
419            p.update(&json!({"item": 1, "status": "bogus"}), 32)
420                .is_err()
421        );
422        assert!(p.update(&json!({}), 32).is_err());
423        assert!(Plan::create("", &[], 32).is_err());
424        assert!(Plan::create("g", &[json!("a"), json!("b")], 1).is_err());
425        assert!(
426            p.update(&json!({"insert": {"title": "x"}}), 3).is_err(),
427            "cap on insert"
428        );
429        // Round trip.
430        let v = p.to_value();
431        let back: Plan = serde_json::from_value(v).unwrap();
432        assert_eq!(back, p);
433    }
434}