Skip to main content

mermaid_cli/providers/tool/
tasks.rs

1//! The task checklist tools: `task_create`, `task_update`, `task_list`.
2//!
3//! The model's planning surface for multi-step work. Granular and
4//! id-addressed (Claude Code's Task-tool shape) with batch arrays in both
5//! create and update (codex's one-call ergonomics): the initial plan lands in
6//! one `task_create`, and "complete #2 + start #3" is one `task_update`.
7//! Every mutation goes through the session's `TaskBroker` (single writer),
8//! which publishes a snapshot the TUI renders live.
9//!
10//! Planning mutates nothing outside the session, so like `ask_user_question`
11//! these tools are ungated (they never touch the policy gate) and run in
12//! every safety mode, including `read_only`.
13//!
14//! Discipline ("at most one `in_progress`", no pending->completed jumps) is
15//! soft-enforced: violations come back as advisory notes in the tool result,
16//! never rejections — a hard reject risks retry loops, while silence (codex)
17//! lets malformed checklists render unremarked.
18
19use std::time::Instant;
20
21use async_trait::async_trait;
22
23use mermaid_domain::checklist::{
24    ChecklistEdit, ChecklistItem, ChecklistOrigin, ChecklistSpec, ChecklistStatus, ChecklistStore,
25};
26use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
27
28use super::super::ctx::ExecContext;
29use super::ToolExecutor;
30
31pub struct TaskCreateTool;
32pub struct TaskUpdateTool;
33pub struct TaskListTool;
34
35/// Tool result line for one task: `#3 [in_progress] wire the broker`.
36fn task_line(task: &ChecklistItem) -> String {
37    format!("#{} [{}] {}", task.id, task.status.as_str(), task.subject)
38}
39
40/// The checklist as the model sees it from `task_list`: numbered lines plus
41/// the progress footer, with per-task evidence indented underneath (the
42/// model's own audit trail of what actually happened while each ran).
43fn render_list(store: &ChecklistStore) -> String {
44    if store.is_empty() {
45        return "No tasks.".to_string();
46    }
47    let mut out = String::new();
48    for task in store.visible() {
49        out.push_str(&task_line(task));
50        out.push('\n');
51        if let Some(desc) = &task.description {
52            out.push_str(&format!("    {desc}\n"));
53        }
54        // Show the tail of the evidence ring — enough to re-anchor after a
55        // compaction without ballooning the result.
56        for entry in task.evidence.iter().rev().take(3).rev() {
57            out.push_str(&format!(
58                "    evidence: {} {} ({})\n",
59                entry.tool, entry.target, entry.status
60            ));
61        }
62    }
63    out.push_str(&store.progress_string());
64    out
65}
66
67/// Graceful degradation for contexts with no broker (bare test harnesses):
68/// the model is told to carry on rather than erroring into a retry loop.
69fn no_broker(secs: f64) -> ToolOutcome {
70    ToolOutcome::success(
71        "Task tracking is unavailable in this context; proceed without it.",
72        "tasks unavailable",
73        secs,
74    )
75}
76
77/// Plan mode firewalls the checklist WRITERS: implementation steps belong in
78/// the plan file's Tasks section, which seeds the checklist when the plan is
79/// approved. Without a hard error models conflate the two planning surfaces
80/// (Codex shipped the same runtime error for the same reason). `task_list`
81/// stays available — reading is harmless.
82fn plan_mode_block(ctx: &crate::providers::ExecContext, secs: f64) -> Option<ToolOutcome> {
83    // Only an explicit `allow` in the plan profile unblocks the writers —
84    // `auto`/`ask` collapse to deny (ungated tools have no approval path).
85    if ctx.plan_permissions.tasks == mermaid_domain::PlanPermLevel::Allow {
86        return None;
87    }
88    ctx.plan_file.as_ref().map(|_| {
89        ToolOutcome::error(
90            "task tools are disabled in plan mode: the checklist is seeded from the \
91             approved plan. Put implementation steps in the plan file's Tasks section \
92             instead."
93                .to_string(),
94            secs,
95        )
96    })
97}
98
99fn metadata(action: &str, store: &ChecklistStore) -> ToolRunMetadata {
100    let (completed, total) = store.counts();
101    ToolRunMetadata {
102        detail: ToolMetadata::Tasks {
103            action: action.to_string(),
104            completed: completed as u32,
105            total: total as u32,
106        },
107        ..ToolRunMetadata::default()
108    }
109}
110
111fn parse_specs(args: &serde_json::Value) -> Result<Vec<ChecklistSpec>, String> {
112    let items = args
113        .get("tasks")
114        .and_then(|t| t.as_array())
115        .ok_or("task_create requires a `tasks` array")?;
116    if items.is_empty() {
117        return Err("`tasks` must not be empty".to_string());
118    }
119    items
120        .iter()
121        .enumerate()
122        .map(|(i, item)| {
123            let subject = item
124                .get("subject")
125                .and_then(|s| s.as_str())
126                .filter(|s| !s.trim().is_empty())
127                .ok_or_else(|| format!("tasks[{i}] is missing `subject`"))?;
128            let active_form = item
129                .get("active_form")
130                .and_then(|s| s.as_str())
131                .filter(|s| !s.trim().is_empty())
132                .ok_or_else(|| format!("tasks[{i}] is missing `active_form`"))?;
133            let status = item.get("status").and_then(|s| s.as_str());
134            let in_progress = match status {
135                None | Some("pending") => false,
136                Some("in_progress") => true,
137                Some(other) => {
138                    return Err(format!(
139                        "tasks[{i}]: initial status must be \"pending\" or \"in_progress\", got {other:?}"
140                    ));
141                },
142            };
143            Ok(ChecklistSpec {
144                subject: subject.to_string(),
145                active_form: active_form.to_string(),
146                description: item
147                    .get("description")
148                    .and_then(|s| s.as_str())
149                    .map(str::to_string),
150                in_progress,
151            })
152        })
153        .collect()
154}
155
156fn parse_edits(args: &serde_json::Value) -> Result<Vec<ChecklistEdit>, String> {
157    let items = args
158        .get("updates")
159        .and_then(|t| t.as_array())
160        .ok_or("task_update requires an `updates` array")?;
161    if items.is_empty() {
162        return Err("`updates` must not be empty".to_string());
163    }
164    items
165        .iter()
166        .enumerate()
167        .map(|(i, item)| {
168            let id = item
169                .get("id")
170                .and_then(|v| v.as_u64())
171                .ok_or_else(|| format!("updates[{i}] is missing `id`"))?;
172            let status = match item.get("status").and_then(|s| s.as_str()) {
173                None => None,
174                Some(s) => Some(
175                    ChecklistStatus::parse(s)
176                        .ok_or_else(|| format!("updates[{i}]: unknown status {s:?}"))?,
177                ),
178            };
179            Ok(ChecklistEdit {
180                id: id as u32,
181                status,
182                subject: item
183                    .get("subject")
184                    .and_then(|s| s.as_str())
185                    .map(str::to_string),
186                active_form: item
187                    .get("active_form")
188                    .and_then(|s| s.as_str())
189                    .map(str::to_string),
190                description: item
191                    .get("description")
192                    .and_then(|s| s.as_str())
193                    .map(str::to_string),
194            })
195        })
196        .collect()
197}
198
199#[async_trait]
200impl ToolExecutor for TaskCreateTool {
201    fn name(&self) -> &'static str {
202        "task_create"
203    }
204
205    fn schema(&self) -> ToolDefinition {
206        ToolDefinition {
207            name: "task_create".to_string(),
208            description: "Create tasks on your session checklist, which the user sees live in \
209                the terminal. Use it at the START of multi-step work (3+ distinct steps): plan \
210                the whole job and create ALL initial tasks in ONE call, in execution order. \
211                Skip it entirely for trivial or single-step requests — a one-item checklist is \
212                noise. Each task needs a short imperative `subject` (\"Wire the broker\") and a \
213                present-tense `active_form` (\"Wiring the broker\") shown on the spinner while \
214                it runs. Mark at most one task `in_progress`. Add tasks later as you discover \
215                work; give an `explanation` when a mid-run addition reshapes the plan."
216                .to_string(),
217            input_schema: serde_json::json!({
218                "type": "object",
219                "properties": {
220                    "tasks": {
221                        "type": "array",
222                        "description": "Tasks to add, in execution order. Create the full initial plan in one call.",
223                        "items": {
224                            "type": "object",
225                            "properties": {
226                                "subject": {
227                                    "type": "string",
228                                    "description": "Short imperative step, e.g. \"Add the config flag\". Meaningful and verifiable, not vague."
229                                },
230                                "active_form": {
231                                    "type": "string",
232                                    "description": "Present-tense form shown while running, e.g. \"Adding the config flag\"."
233                                },
234                                "description": {
235                                    "type": "string",
236                                    "description": "Optional detail: acceptance criteria, files involved, constraints."
237                                },
238                                "status": {
239                                    "type": "string",
240                                    "enum": ["pending", "in_progress"],
241                                    "description": "Initial status (default pending). At most one task in_progress across the whole list."
242                                }
243                            },
244                            "required": ["subject", "active_form"]
245                        }
246                    },
247                    "explanation": {
248                        "type": "string",
249                        "description": "One-line rationale, when this call reshapes an existing plan. Shown to the user."
250                    }
251                },
252                "required": ["tasks"]
253            }),
254        }
255    }
256
257    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
258        let started = Instant::now();
259        let secs = || started.elapsed().as_secs_f64();
260        if let Some(blocked) = plan_mode_block(&ctx, secs()) {
261            return blocked;
262        }
263        let Some(broker) = ctx.tasks.clone() else {
264            return no_broker(secs());
265        };
266        let specs = match parse_specs(&args) {
267            Ok(s) => s,
268            Err(e) => return ToolOutcome::error(e, secs()),
269        };
270        let count = specs.len();
271        let (created, store) = broker.create(specs, ChecklistOrigin::Model).await;
272        let mut out = format!("Created {count} task(s):\n");
273        for task in &created {
274            out.push_str(&task_line(task));
275            out.push('\n');
276        }
277        out.push_str(&store.progress_string());
278        // Creation can violate single-in_progress too (e.g. adding an
279        // in_progress task while another is active) — same advisory path.
280        for note in mermaid_domain::advisory_notes(&store, &[], &store) {
281            out.push('\n');
282            out.push_str(&note);
283        }
284        ToolOutcome::success(out, format!("created {count} task(s)"), secs())
285            .with_metadata(metadata("create", &store))
286    }
287}
288
289#[async_trait]
290impl ToolExecutor for TaskUpdateTool {
291    fn name(&self) -> &'static str {
292        "task_update"
293    }
294
295    fn schema(&self) -> ToolDefinition {
296        ToolDefinition {
297            name: "task_update".to_string(),
298            description: "Update checklist tasks by id (from task_create). Batch related \
299                transitions in one call — completing one task and starting the next is ONE \
300                call with two updates. Keep at most one task in_progress: set it \
301                in_progress BEFORE you start the work and completed IMMEDIATELY after it is \
302                done and verified — never batch-complete at the end, and never jump a task \
303                from pending straight to completed. Only mark completed when the work truly \
304                succeeded; if you hit a blocker, mark the stuck task blocked with an \
305                `explanation`, create a task for the blocker, and mark that one \
306                in_progress. When the plan changes shape (splitting, merging, dropping \
307                work), update or delete tasks and say why in `explanation` — do not let the \
308                checklist go stale while you work."
309                .to_string(),
310            input_schema: serde_json::json!({
311                "type": "object",
312                "properties": {
313                    "updates": {
314                        "type": "array",
315                        "description": "Differential updates, applied in order. Only `id` is required; omitted fields stay unchanged.",
316                        "items": {
317                            "type": "object",
318                            "properties": {
319                                "id": {
320                                    "type": "integer",
321                                    "description": "Task id from task_create."
322                                },
323                                "status": {
324                                    "type": "string",
325                                    "enum": ["pending", "in_progress", "blocked", "completed", "deleted"],
326                                    "description": "New status. \"blocked\" marks a task stalled on something outside it (pair it with a new task for the blocker); \"deleted\" permanently removes the task from the list."
327                                },
328                                "subject": { "type": "string", "description": "Replacement subject." },
329                                "active_form": { "type": "string", "description": "Replacement active form." },
330                                "description": { "type": "string", "description": "Replacement description." }
331                            },
332                            "required": ["id"]
333                        }
334                    },
335                    "explanation": {
336                        "type": "string",
337                        "description": "One-line rationale for scope pivots (deleting, reordering, or reshaping work). Shown to the user."
338                    }
339                },
340                "required": ["updates"]
341            }),
342        }
343    }
344
345    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
346        let started = Instant::now();
347        let secs = || started.elapsed().as_secs_f64();
348        if let Some(blocked) = plan_mode_block(&ctx, secs()) {
349            return blocked;
350        }
351        let Some(broker) = ctx.tasks.clone() else {
352            return no_broker(secs());
353        };
354        let edits = match parse_edits(&args) {
355            Ok(e) => e,
356            Err(e) => return ToolOutcome::error(e, secs()),
357        };
358        let (report, store) = broker.update(edits.clone()).await;
359        if report.applied.is_empty() {
360            return ToolOutcome::error(
361                format!("No updates applied:\n{}", report.errors.join("\n")),
362                secs(),
363            );
364        }
365        let mut out = String::new();
366        for edit in &edits {
367            if !report.applied.contains(&edit.id) {
368                continue;
369            }
370            match edit.status {
371                Some(status) => {
372                    out.push_str(&format!("#{} -> {}\n", edit.id, status.as_str()));
373                },
374                None => out.push_str(&format!("#{} updated\n", edit.id)),
375            }
376        }
377        for err in &report.errors {
378            out.push_str(&format!("error: {err}\n"));
379        }
380        out.push_str(&store.progress_string());
381        for note in &report.notes {
382            out.push('\n');
383            out.push_str(note);
384        }
385        ToolOutcome::success(out, store.progress_string(), secs())
386            .with_metadata(metadata("update", &store))
387    }
388}
389
390#[async_trait]
391impl ToolExecutor for TaskListTool {
392    fn name(&self) -> &'static str {
393        "task_list"
394    }
395
396    fn schema(&self) -> ToolDefinition {
397        ToolDefinition {
398            name: "task_list".to_string(),
399            description: "Read back the current session checklist: every task with its id, \
400                status, description, and recent evidence (the work recorded while it was in \
401                progress). Call it to re-anchor after a context compaction, or when unsure of \
402                a task id or the plan's current state. The user also sees this list live in \
403                the terminal, so you never need to repeat its contents in prose."
404                .to_string(),
405            input_schema: serde_json::json!({
406                "type": "object",
407                "properties": {}
408            }),
409        }
410    }
411
412    async fn execute(&self, _args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
413        let started = Instant::now();
414        let secs = || started.elapsed().as_secs_f64();
415        let Some(broker) = ctx.tasks.clone() else {
416            return no_broker(secs());
417        };
418        let store = broker.snapshot();
419        ToolOutcome::success(render_list(&store), store.progress_string(), secs())
420            .with_metadata(metadata("list", &store))
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::providers::ctx::test_exec_context;
428    use crate::providers::tasks::TaskBroker;
429    use mermaid_domain::{ToolCallId, TurnId};
430    use std::path::PathBuf;
431
432    fn ctx_with_broker() -> (
433        ExecContext,
434        TaskBroker,
435        tokio::sync::mpsc::Receiver<mermaid_domain::Msg>,
436    ) {
437        let (mut ctx, _progress) =
438            test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
439        let (tx, rx) = tokio::sync::mpsc::channel(32);
440        let broker = TaskBroker::new(tx);
441        ctx.tasks = Some(broker.clone());
442        (ctx, broker, rx)
443    }
444
445    fn create_args(n: usize) -> serde_json::Value {
446        let tasks: Vec<serde_json::Value> = (0..n)
447            .map(|i| {
448                serde_json::json!({
449                    "subject": format!("task {i}"),
450                    "active_form": format!("doing task {i}"),
451                    "status": if i == 0 { "in_progress" } else { "pending" },
452                })
453            })
454            .collect();
455        serde_json::json!({ "tasks": tasks })
456    }
457
458    #[tokio::test]
459    async fn create_returns_ids_and_progress() {
460        let (ctx, _broker, _rx) = ctx_with_broker();
461        let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
462        assert!(outcome.error.is_none(), "{:?}", outcome.error);
463        assert!(outcome.model_content.contains("#1 [in_progress] task 0"));
464        assert!(outcome.model_content.contains("#3 [pending] task 2"));
465        assert!(outcome.model_content.contains("Tasks 0/3"));
466    }
467
468    #[tokio::test]
469    async fn update_batches_and_appends_notes() {
470        let (ctx, broker, _rx) = ctx_with_broker();
471        let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
472        assert!(outcome.error.is_none());
473
474        let (ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
475        let mut ctx2 = ctx2;
476        ctx2.tasks = Some(broker.clone());
477        let outcome = TaskUpdateTool
478            .execute(
479                serde_json::json!({ "updates": [
480                    { "id": 1, "status": "completed" },
481                    { "id": 2, "status": "in_progress" },
482                    { "id": 3, "status": "in_progress" },
483                ]}),
484                ctx2,
485            )
486            .await;
487        assert!(outcome.error.is_none());
488        assert!(outcome.model_content.contains("#1 -> completed"));
489        assert!(outcome.model_content.contains("Tasks 1/3"));
490        // Two in_progress after the batch: the advisory note must land.
491        assert!(
492            outcome
493                .model_content
494                .contains("at most one task in_progress")
495        );
496        assert_eq!(outcome.summary, "Tasks 1/3");
497    }
498
499    #[tokio::test]
500    async fn update_all_unknown_ids_is_an_error() {
501        let (ctx, _broker, _rx) = ctx_with_broker();
502        let outcome = TaskUpdateTool
503            .execute(
504                serde_json::json!({ "updates": [{ "id": 42, "status": "completed" }]}),
505                ctx,
506            )
507            .await;
508        assert!(outcome.error.is_some());
509        assert!(
510            outcome
511                .error
512                .as_deref()
513                .unwrap_or_default()
514                .contains("no such task")
515        );
516    }
517
518    #[tokio::test]
519    async fn list_renders_descriptions_and_evidence() {
520        let (ctx, broker, _rx) = ctx_with_broker();
521        let outcome = TaskCreateTool
522            .execute(
523                serde_json::json!({ "tasks": [{
524                    "subject": "wire broker",
525                    "active_form": "wiring broker",
526                    "description": "through ExecContext",
527                    "status": "in_progress",
528                }]}),
529                ctx,
530            )
531            .await;
532        assert!(outcome.error.is_none());
533        broker
534            .record_evidence(mermaid_domain::EvidenceEntry {
535                tool: "edit_file".into(),
536                target: "src/x.rs".into(),
537                status: "ok".into(),
538            })
539            .await;
540
541        let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
542        ctx2.tasks = Some(broker);
543        let outcome = TaskListTool.execute(serde_json::json!({}), ctx2).await;
544        assert!(
545            outcome
546                .model_content
547                .contains("#1 [in_progress] wire broker")
548        );
549        assert!(outcome.model_content.contains("    through ExecContext"));
550        assert!(
551            outcome
552                .model_content
553                .contains("evidence: edit_file src/x.rs (ok)")
554        );
555    }
556
557    #[tokio::test]
558    async fn missing_broker_degrades_gracefully() {
559        let (ctx, _p) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
560        let outcome = TaskCreateTool.execute(create_args(1), ctx).await;
561        assert!(outcome.error.is_none());
562        assert!(outcome.model_content.contains("unavailable"));
563    }
564
565    #[tokio::test]
566    async fn create_rejects_malformed_args() {
567        let (ctx, _broker, _rx) = ctx_with_broker();
568        let outcome = TaskCreateTool
569            .execute(serde_json::json!({ "tasks": [] }), ctx)
570            .await;
571        assert!(outcome.error.is_some());
572
573        let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
574        let (tx, _rx2) = tokio::sync::mpsc::channel(8);
575        ctx2.tasks = Some(TaskBroker::new(tx));
576        let outcome = TaskCreateTool
577            .execute(serde_json::json!({ "tasks": [{ "subject": "x" }] }), ctx2)
578            .await;
579        assert!(
580            outcome
581                .error
582                .as_deref()
583                .unwrap_or_default()
584                .contains("active_form")
585        );
586    }
587}