atman_runtime/tools/
plan.rs1use 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}