Skip to main content

sgr_agent_tools/
plan.rs

1//! UpdatePlanTool — task checklist that persists to disk.
2//!
3//! Compatible with solo-factory `/plan` format (spec.md + plan.md).
4//! LLM calls `update_plan` to record progress. Tool writes `plan.md` to cwd
5//! and stores state in AgentContext typed store.
6//!
7//! Format: `- [x] completed` / `- [~] in progress` / `- [ ] pending`
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use sgr_agent_core::agent_tool::{Tool, ToolError, ToolOutput, parse_args};
13use sgr_agent_core::context::AgentContext;
14use sgr_agent_core::schema::json_schema_for;
15
16/// A single step in the agent's plan.
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18pub struct PlanStep {
19    /// Description of the step.
20    pub step: String,
21    /// Status: "pending", "in_progress", or "completed".
22    pub status: String,
23}
24
25impl PlanStep {
26    fn checkbox(&self) -> &str {
27        match self.status.as_str() {
28            "completed" => "[x]",
29            "in_progress" => "[~]",
30            _ => "[ ]",
31        }
32    }
33}
34
35/// Current plan state — stored in AgentContext typed store.
36/// Also written to `plan.md` in working directory.
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct PlanState {
39    pub steps: Vec<PlanStep>,
40    pub explanation: Option<String>,
41}
42
43impl PlanState {
44    /// Summary: "3/5 done — current step"
45    pub fn summary(&self) -> String {
46        let done = self
47            .steps
48            .iter()
49            .filter(|s| s.status == "completed")
50            .count();
51        let total = self.steps.len();
52        let current = self.steps.iter().find(|s| s.status == "in_progress");
53        match current {
54            Some(s) => format!("{done}/{total} done — {}", s.step),
55            None if done == total && total > 0 => format!("{done}/{total} done"),
56            _ => format!("{done}/{total} steps"),
57        }
58    }
59
60    /// Render as markdown checklist (solo-factory compatible).
61    pub fn to_markdown(&self) -> String {
62        let mut md = String::from("# Plan\n\n");
63        if let Some(ref explanation) = self.explanation {
64            md.push_str(&format!("{explanation}\n\n"));
65        }
66        md.push_str("## Tasks\n\n");
67        for (i, step) in self.steps.iter().enumerate() {
68            md.push_str(&format!(
69                "- {} Task {}: {}\n",
70                step.checkbox(),
71                i + 1,
72                step.step
73            ));
74        }
75        md
76    }
77}
78
79#[derive(Deserialize, JsonSchema)]
80struct UpdatePlanArgs {
81    /// Optional explanation of the plan or current thinking.
82    #[serde(default)]
83    explanation: Option<String>,
84    /// The list of steps with status (pending/in_progress/completed).
85    plan: Vec<PlanStep>,
86}
87
88/// Checklist tool — LLM records task plan, persisted to `plan.md`.
89///
90/// Stores in AgentContext typed store + writes to `{cwd}/plan.md`.
91pub struct UpdatePlanTool;
92
93#[async_trait::async_trait]
94impl Tool for UpdatePlanTool {
95    fn name(&self) -> &str {
96        "update_plan"
97    }
98    fn description(&self) -> &str {
99        "Update the task plan checklist. Provide steps with status (pending/in_progress/completed). \
100         At most one step should be in_progress at a time. Plan is saved to plan.md."
101    }
102    fn is_system(&self) -> bool {
103        true
104    }
105    fn parameters_schema(&self) -> Value {
106        json_schema_for::<UpdatePlanArgs>()
107    }
108
109    async fn execute(&self, args: Value, ctx: &mut AgentContext) -> Result<ToolOutput, ToolError> {
110        let a: UpdatePlanArgs = parse_args(&args)?;
111        let state = PlanState {
112            steps: a.plan,
113            explanation: a.explanation,
114        };
115
116        // Persist to disk
117        let plan_path = ctx.cwd.join("plan.md");
118        let md = state.to_markdown();
119        std::fs::write(&plan_path, &md)
120            .map_err(|e| ToolError::Execution(format!("write plan.md: {e}")))?;
121
122        // Store in context for UI
123        let summary = state.summary();
124        ctx.insert(state);
125
126        Ok(ToolOutput::text(format!(
127            "Plan updated: {summary} (saved to plan.md)"
128        )))
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[tokio::test]
137    async fn test_update_plan() {
138        let tool = UpdatePlanTool;
139        let tmp = std::env::temp_dir().join("sgr_plan_test");
140        let _ = std::fs::create_dir_all(&tmp);
141        let mut ctx = AgentContext::new().with_cwd(&tmp);
142
143        let result = tool
144            .execute(
145                serde_json::json!({
146                    "plan": [
147                        {"step": "Read file", "status": "completed"},
148                        {"step": "Fix bug", "status": "in_progress"},
149                        {"step": "Run tests", "status": "pending"}
150                    ]
151                }),
152                &mut ctx,
153            )
154            .await
155            .unwrap();
156
157        assert!(result.content.contains("1/3 done"));
158        assert!(result.content.contains("Fix bug"));
159        assert!(result.content.contains("plan.md"));
160
161        // Check typed store
162        let state = ctx.get_typed::<PlanState>().unwrap();
163        assert_eq!(state.steps.len(), 3);
164
165        // Check file on disk
166        let md = std::fs::read_to_string(tmp.join("plan.md")).unwrap();
167        assert!(md.contains("[x] Task 1: Read file"));
168        assert!(md.contains("[~] Task 2: Fix bug"));
169        assert!(md.contains("[ ] Task 3: Run tests"));
170
171        let _ = std::fs::remove_dir_all(&tmp);
172    }
173
174    #[test]
175    fn plan_to_markdown() {
176        let state = PlanState {
177            steps: vec![
178                PlanStep {
179                    step: "A".into(),
180                    status: "completed".into(),
181                },
182                PlanStep {
183                    step: "B".into(),
184                    status: "in_progress".into(),
185                },
186                PlanStep {
187                    step: "C".into(),
188                    status: "pending".into(),
189                },
190            ],
191            explanation: Some("Fix the auth bug".into()),
192        };
193        let md = state.to_markdown();
194        assert!(md.contains("# Plan"));
195        assert!(md.contains("Fix the auth bug"));
196        assert!(md.contains("- [x] Task 1: A"));
197        assert!(md.contains("- [~] Task 2: B"));
198        assert!(md.contains("- [ ] Task 3: C"));
199    }
200
201    #[test]
202    fn plan_summary() {
203        let state = PlanState {
204            steps: vec![
205                PlanStep {
206                    step: "A".into(),
207                    status: "completed".into(),
208                },
209                PlanStep {
210                    step: "B".into(),
211                    status: "completed".into(),
212                },
213                PlanStep {
214                    step: "C".into(),
215                    status: "pending".into(),
216                },
217            ],
218            explanation: None,
219        };
220        assert_eq!(state.summary(), "2/3 steps");
221    }
222}