Skip to main content

atman_runtime/tools/
plan.rs

1use std::sync::Arc;
2
3use crate::error::RuntimeError;
4use crate::memory::plan::{Plan, PlanStore};
5use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct PlanWrite {
9    pub store: Arc<PlanStore>,
10}
11
12impl Tool for PlanWrite {
13    fn name(&self) -> &str {
14        "plan.write"
15    }
16
17    fn tier(&self) -> Tier {
18        Tier::One
19    }
20
21    fn description(&self) -> Option<&str> {
22        Some(
23            "Create or overwrite the active high-level plan for a multi-step task. \
24             atman injects this plan into every LLM call, so use it for durable \
25             strategy across several steps, files, tool calls, or turns. Use \
26             memory.todo.* only for smaller execution items inside a plan step; do \
27             not duplicate the same work in both systems.\n\n\
28             Best practice: call plan.write early for non-trivial work, usually \
29             right after setting the goal. Break work into 3-8 ordered milestones. \
30             Each step should be a single actionable verb: 'read auth.rs', 'add \
31             validate_token function', 'write regression test for empty token'. \
32             Update the plan if the strategy changes. Use plan.tick after a step \
33             is truly complete.",
34        )
35    }
36
37    fn input_schema(&self) -> serde_json::Value {
38        serde_json::json!({
39            "type": "object",
40            "properties": {
41                "id": {"type": "string"},
42                "title": {"type": "string"},
43                "steps": {"type": "array", "items": {"type": "string"}}
44            },
45            "required": ["title", "steps"]
46        })
47    }
48
49    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
50        Box::pin(async move {
51            let title = required_string(&args, "title")?;
52            let steps = required_string_list(&args, "steps")?;
53            let id = match args.named("id") {
54                Some(Value::Str(s)) if !s.is_empty() => s.clone(),
55                _ => slug_from_title(&title),
56            };
57            let plan = Plan::new(id.clone(), title, steps);
58            self.store
59                .upsert(plan)
60                .await
61                .map_err(|e| RuntimeError::ToolFailed(format!("plan.write: {e}")))?;
62            Ok(Value::Str(id))
63        })
64    }
65}
66
67pub struct PlanRead {
68    pub store: Arc<PlanStore>,
69}
70
71impl Tool for PlanRead {
72    fn name(&self) -> &str {
73        "plan.read"
74    }
75
76    fn tier(&self) -> Tier {
77        Tier::Zero
78    }
79
80    fn description(&self) -> Option<&str> {
81        Some(
82            "Read the current plan as a markdown checklist with progress markers. \
83             Without `id`, returns the most recently updated plan. \
84             Returns empty string if no plan exists. Call this to refresh your \
85             memory of the high-level route before starting or revising work.",
86        )
87    }
88
89    fn input_schema(&self) -> serde_json::Value {
90        serde_json::json!({
91            "type": "object",
92            "properties": {
93                "id": {"type": "string"}
94            }
95        })
96    }
97
98    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
99        Box::pin(async move {
100            let plan = match args.named("id") {
101                Some(Value::Str(s)) if !s.is_empty() => self
102                    .store
103                    .get(s)
104                    .await
105                    .map_err(|e| RuntimeError::ToolFailed(format!("plan.read: {e}")))?,
106                _ => self
107                    .store
108                    .latest()
109                    .await
110                    .map_err(|e| RuntimeError::ToolFailed(format!("plan.read: {e}")))?,
111            };
112            let Some(plan) = plan else {
113                return Ok(Value::Str(String::new()));
114            };
115            Ok(Value::Str(render_plan(&plan)))
116        })
117    }
118}
119
120pub struct PlanTick {
121    pub store: Arc<PlanStore>,
122}
123
124impl Tool for PlanTick {
125    fn name(&self) -> &str {
126        "plan.tick"
127    }
128
129    fn tier(&self) -> Tier {
130        Tier::One
131    }
132
133    fn description(&self) -> Option<&str> {
134        Some(
135            "Mark a plan step as done (0-based index). Without `id`, targets the \
136             latest plan. Returns the updated plan as markdown. Call this right \
137             after completing a high-level plan step, not after every small todo.",
138        )
139    }
140
141    fn input_schema(&self) -> serde_json::Value {
142        serde_json::json!({
143            "type": "object",
144            "properties": {
145                "id": {"type": "string"},
146                "step_index": {"type": "integer"}
147            },
148            "required": ["step_index"]
149        })
150    }
151
152    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
153        Box::pin(async move {
154            let step_index = required_usize(&args, "step_index")?;
155            let plan_id = match args.named("id") {
156                Some(Value::Str(s)) if !s.is_empty() => s.clone(),
157                _ => match self
158                    .store
159                    .latest()
160                    .await
161                    .map_err(|e| RuntimeError::ToolFailed(format!("plan.tick: {e}")))?
162                {
163                    Some(p) => p.id,
164                    None => {
165                        return Err(RuntimeError::ToolFailed(
166                            "plan.tick: no plan exists yet — call plan.write first".into(),
167                        ));
168                    }
169                },
170            };
171            self.store
172                .tick(&plan_id, step_index)
173                .await
174                .map_err(|e| RuntimeError::ToolFailed(format!("plan.tick: {e}")))?;
175            let plan = self
176                .store
177                .get(&plan_id)
178                .await
179                .map_err(|e| RuntimeError::ToolFailed(format!("plan.tick: {e}")))?
180                .ok_or_else(|| {
181                    RuntimeError::ToolFailed(format!("plan.tick: plan `{plan_id}` disappeared"))
182                })?;
183            Ok(Value::Str(render_plan(&plan)))
184        })
185    }
186}
187
188fn required_string(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
189    match args.named(name) {
190        Some(Value::Str(s)) => Ok(s.clone()),
191        Some(other) => Err(RuntimeError::TypeMismatch {
192            expected: "string".into(),
193            actual: other.kind_name().into(),
194        }),
195        None => Err(RuntimeError::MissingArg(name.into())),
196    }
197}
198
199fn required_string_list(args: &ToolArgs, name: &str) -> Result<Vec<String>, RuntimeError> {
200    match args.named(name) {
201        Some(Value::List(items)) => {
202            let mut out = Vec::with_capacity(items.len());
203            for it in items {
204                match it {
205                    Value::Str(s) => out.push(s.clone()),
206                    other => {
207                        return Err(RuntimeError::TypeMismatch {
208                            expected: "list of strings".into(),
209                            actual: other.kind_name().into(),
210                        });
211                    }
212                }
213            }
214            Ok(out)
215        }
216        Some(other) => Err(RuntimeError::TypeMismatch {
217            expected: "list of strings".into(),
218            actual: other.kind_name().into(),
219        }),
220        None => Err(RuntimeError::MissingArg(name.into())),
221    }
222}
223
224fn required_usize(args: &ToolArgs, name: &str) -> Result<usize, RuntimeError> {
225    match args.named(name) {
226        Some(Value::Int(n)) if *n >= 0 => Ok(*n as usize),
227        Some(other) => Err(RuntimeError::TypeMismatch {
228            expected: "non-negative int".into(),
229            actual: other.kind_name().into(),
230        }),
231        None => Err(RuntimeError::MissingArg(name.into())),
232    }
233}
234
235pub fn render_plan(plan: &Plan) -> String {
236    let (done, total) = plan.progress();
237    let mut out = format!(
238        "# Plan: {}\n_id: {} · {}/{} done_\n\n",
239        plan.title, plan.id, done, total
240    );
241    for step in &plan.steps {
242        let mark = if step.done { "[x]" } else { "[ ]" };
243        out.push_str(&format!("- {mark} {}\n", step.text));
244    }
245    out
246}
247
248fn slug_from_title(title: &str) -> String {
249    let mut out = String::with_capacity(title.len().min(48));
250    let mut last_dash = false;
251    for c in title.chars().take(48) {
252        if c.is_ascii_alphanumeric() {
253            out.push(c.to_ascii_lowercase());
254            last_dash = false;
255        } else if !last_dash && !out.is_empty() {
256            out.push('-');
257            last_dash = true;
258        }
259    }
260    while out.ends_with('-') {
261        out.pop();
262    }
263    if out.is_empty() {
264        format!("plan-{}", uuid::Uuid::now_v7().simple())
265    } else {
266        out
267    }
268}