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/// Checklist advisory notes ("keep one task in_progress") are coaching, so
78/// they ride on the same switch as the guidance pack.
79fn coaching_enabled(ctx: &ExecContext) -> bool {
80    ctx.config.guidance_pack_enabled(&ctx.model_id)
81}
82
83fn metadata(action: &str, store: &ChecklistStore) -> ToolRunMetadata {
84    let (completed, total) = store.counts();
85    ToolRunMetadata {
86        detail: ToolMetadata::Tasks {
87            action: action.to_string(),
88            completed: completed as u32,
89            total: total as u32,
90        },
91        ..ToolRunMetadata::default()
92    }
93}
94
95fn parse_specs(args: &serde_json::Value) -> Result<Vec<ChecklistSpec>, String> {
96    let items = args
97        .get("tasks")
98        .and_then(|t| t.as_array())
99        .ok_or("task_create requires a `tasks` array")?;
100    if items.is_empty() {
101        return Err("`tasks` must not be empty".to_string());
102    }
103    items
104        .iter()
105        .enumerate()
106        .map(|(i, item)| {
107            let subject = item
108                .get("subject")
109                .and_then(|s| s.as_str())
110                .filter(|s| !s.trim().is_empty())
111                .ok_or_else(|| format!("tasks[{i}] is missing `subject`"))?;
112            let active_form = item
113                .get("active_form")
114                .and_then(|s| s.as_str())
115                .filter(|s| !s.trim().is_empty())
116                .ok_or_else(|| format!("tasks[{i}] is missing `active_form`"))?;
117            let status = item.get("status").and_then(|s| s.as_str());
118            let in_progress = match status {
119                None | Some("pending") => false,
120                Some("in_progress") => true,
121                Some(other) => {
122                    return Err(format!(
123                        "tasks[{i}]: initial status must be \"pending\" or \"in_progress\", got {other:?}"
124                    ));
125                },
126            };
127            Ok(ChecklistSpec {
128                subject: subject.to_string(),
129                active_form: active_form.to_string(),
130                description: item
131                    .get("description")
132                    .and_then(|s| s.as_str())
133                    .map(str::to_string),
134                in_progress,
135            })
136        })
137        .collect()
138}
139
140fn parse_edits(args: &serde_json::Value) -> Result<Vec<ChecklistEdit>, String> {
141    let items = args
142        .get("updates")
143        .and_then(|t| t.as_array())
144        .ok_or("task_update requires an `updates` array")?;
145    if items.is_empty() {
146        return Err("`updates` must not be empty".to_string());
147    }
148    items
149        .iter()
150        .enumerate()
151        .map(|(i, item)| {
152            let id = item
153                .get("id")
154                .and_then(|v| v.as_u64())
155                .ok_or_else(|| format!("updates[{i}] is missing `id`"))?;
156            let status = match item.get("status").and_then(|s| s.as_str()) {
157                None => None,
158                Some(s) => Some(
159                    ChecklistStatus::parse(s)
160                        .ok_or_else(|| format!("updates[{i}]: unknown status {s:?}"))?,
161                ),
162            };
163            Ok(ChecklistEdit {
164                id: id as u32,
165                status,
166                subject: item
167                    .get("subject")
168                    .and_then(|s| s.as_str())
169                    .map(str::to_string),
170                active_form: item
171                    .get("active_form")
172                    .and_then(|s| s.as_str())
173                    .map(str::to_string),
174                description: item
175                    .get("description")
176                    .and_then(|s| s.as_str())
177                    .map(str::to_string),
178            })
179        })
180        .collect()
181}
182
183#[async_trait]
184impl ToolExecutor for TaskCreateTool {
185    fn name(&self) -> &'static str {
186        "task_create"
187    }
188
189    fn schema(&self) -> ToolDefinition {
190        ToolDefinition {
191            name: "task_create".to_string(),
192            description: "Create tasks on your session checklist, which the user sees live in \
193                the terminal. Each task has a short imperative `subject` (\"Wire the broker\") \
194                and a present-tense `active_form` (\"Wiring the broker\") shown on the spinner \
195                while it runs. An optional `explanation` is shown to the user alongside the \
196                change."
197                .to_string(),
198            input_schema: serde_json::json!({
199                "type": "object",
200                "properties": {
201                    "tasks": {
202                        "type": "array",
203                        "description": "Tasks to add, in execution order.",
204                        "items": {
205                            "type": "object",
206                            "properties": {
207                                "subject": {
208                                    "type": "string",
209                                    "description": "Short imperative step, e.g. \"Add the config flag\"."
210                                },
211                                "active_form": {
212                                    "type": "string",
213                                    "description": "Present-tense form shown while running, e.g. \"Adding the config flag\"."
214                                },
215                                "description": {
216                                    "type": "string",
217                                    "description": "Optional detail: acceptance criteria, files involved, constraints."
218                                },
219                                "status": {
220                                    "type": "string",
221                                    "enum": ["pending", "in_progress"],
222                                    "description": "Initial status (default pending)."
223                                }
224                            },
225                            "required": ["subject", "active_form"]
226                        }
227                    },
228                    "explanation": {
229                        "type": "string",
230                        "description": "One-line rationale, when this call reshapes an existing plan. Shown to the user."
231                    }
232                },
233                "required": ["tasks"]
234            }),
235        }
236    }
237
238    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
239        let started = Instant::now();
240        let secs = || started.elapsed().as_secs_f64();
241        let Some(broker) = ctx.tasks.clone() else {
242            return no_broker(secs());
243        };
244        let specs = match parse_specs(&args) {
245            Ok(s) => s,
246            Err(e) => return ToolOutcome::error(e, Some(secs())),
247        };
248        let count = specs.len();
249        let (created, store) = broker.create(specs, ChecklistOrigin::Model).await;
250        let mut out = format!("Created {count} task(s):\n");
251        for task in &created {
252            out.push_str(&task_line(task));
253            out.push('\n');
254        }
255        out.push_str(&store.progress_string());
256        // Creation can violate single-in_progress too (e.g. adding an
257        // in_progress task while another is active) — same advisory path.
258        if coaching_enabled(&ctx) {
259            for note in mermaid_domain::advisory_notes(&store, &[], &store) {
260                out.push('\n');
261                out.push_str(&note);
262            }
263        }
264        ToolOutcome::success(out, format!("created {count} task(s)"), secs())
265            .with_metadata(metadata("create", &store))
266    }
267}
268
269#[async_trait]
270impl ToolExecutor for TaskUpdateTool {
271    fn name(&self) -> &'static str {
272        "task_update"
273    }
274
275    fn schema(&self) -> ToolDefinition {
276        ToolDefinition {
277            name: "task_update".to_string(),
278            description: "Update checklist tasks by id (from task_create). One call can apply \
279                several updates, in order. Statuses are pending, in_progress, blocked, \
280                completed, and deleted; an optional `explanation` is shown to the user \
281                alongside the change."
282                .to_string(),
283            input_schema: serde_json::json!({
284                "type": "object",
285                "properties": {
286                    "updates": {
287                        "type": "array",
288                        "description": "Differential updates, applied in order. Only `id` is required; omitted fields stay unchanged.",
289                        "items": {
290                            "type": "object",
291                            "properties": {
292                                "id": {
293                                    "type": "integer",
294                                    "description": "Task id from task_create."
295                                },
296                                "status": {
297                                    "type": "string",
298                                    "enum": ["pending", "in_progress", "blocked", "completed", "deleted"],
299                                    "description": "New status. \"blocked\" marks a task stalled on something outside it; \"deleted\" permanently removes the task from the list."
300                                },
301                                "subject": { "type": "string", "description": "Replacement subject." },
302                                "active_form": { "type": "string", "description": "Replacement active form." },
303                                "description": { "type": "string", "description": "Replacement description." }
304                            },
305                            "required": ["id"]
306                        }
307                    },
308                    "explanation": {
309                        "type": "string",
310                        "description": "One-line rationale for scope pivots (deleting, reordering, or reshaping work). Shown to the user."
311                    }
312                },
313                "required": ["updates"]
314            }),
315        }
316    }
317
318    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
319        let started = Instant::now();
320        let secs = || started.elapsed().as_secs_f64();
321        let Some(broker) = ctx.tasks.clone() else {
322            return no_broker(secs());
323        };
324        let edits = match parse_edits(&args) {
325            Ok(e) => e,
326            Err(e) => return ToolOutcome::error(e, Some(secs())),
327        };
328        let (report, store) = broker.update(edits.clone()).await;
329        if report.applied.is_empty() {
330            return ToolOutcome::error(
331                format!("No updates applied:\n{}", report.errors.join("\n")),
332                Some(secs()),
333            );
334        }
335        let mut out = String::new();
336        for edit in &edits {
337            if !report.applied.contains(&edit.id) {
338                continue;
339            }
340            match edit.status {
341                Some(status) => {
342                    out.push_str(&format!("#{} -> {}\n", edit.id, status.as_str()));
343                },
344                None => out.push_str(&format!("#{} updated\n", edit.id)),
345            }
346        }
347        for err in &report.errors {
348            out.push_str(&format!("error: {err}\n"));
349        }
350        out.push_str(&store.progress_string());
351        if coaching_enabled(&ctx) {
352            for note in &report.notes {
353                out.push('\n');
354                out.push_str(note);
355            }
356        }
357        ToolOutcome::success(out, store.progress_string(), secs())
358            .with_metadata(metadata("update", &store))
359    }
360}
361
362#[async_trait]
363impl ToolExecutor for TaskListTool {
364    fn name(&self) -> &'static str {
365        "task_list"
366    }
367
368    fn schema(&self) -> ToolDefinition {
369        ToolDefinition {
370            name: "task_list".to_string(),
371            description: "Read back the current session checklist: every task with its id, \
372                status, description, and recent evidence (the work recorded while it was in \
373                progress). The user sees the same list live in the terminal."
374                .to_string(),
375            input_schema: serde_json::json!({
376                "type": "object",
377                "properties": {}
378            }),
379        }
380    }
381
382    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
383        let _ = args;
384        let started = Instant::now();
385        let secs = || started.elapsed().as_secs_f64();
386        let Some(broker) = ctx.tasks.clone() else {
387            return no_broker(secs());
388        };
389        let store = broker.snapshot();
390        ToolOutcome::success(render_list(&store), store.progress_string(), secs())
391            .with_metadata(metadata("list", &store))
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::providers::ctx::test_exec_context;
399    use crate::providers::tasks::TaskBroker;
400    use mermaid_domain::{ToolCallId, TurnId};
401    use std::path::PathBuf;
402
403    fn ctx_with_broker() -> (
404        ExecContext,
405        TaskBroker,
406        tokio::sync::mpsc::Receiver<mermaid_domain::Msg>,
407    ) {
408        let (mut ctx, _progress) =
409            test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
410        let (tx, rx) = tokio::sync::mpsc::channel(32);
411        let broker = TaskBroker::new(tx);
412        ctx.tasks = Some(broker.clone());
413        (ctx, broker, rx)
414    }
415
416    fn create_args(n: usize) -> serde_json::Value {
417        let tasks: Vec<serde_json::Value> = (0..n)
418            .map(|i| {
419                serde_json::json!({
420                    "subject": format!("task {i}"),
421                    "active_form": format!("doing task {i}"),
422                    "status": if i == 0 { "in_progress" } else { "pending" },
423                })
424            })
425            .collect();
426        serde_json::json!({ "tasks": tasks })
427    }
428
429    #[tokio::test]
430    async fn create_returns_ids_and_progress() {
431        let (ctx, _broker, _rx) = ctx_with_broker();
432        let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
433        assert!(outcome.error.is_none(), "{:?}", outcome.error);
434        assert!(outcome.model_content.contains("#1 [in_progress] task 0"));
435        assert!(outcome.model_content.contains("#3 [pending] task 2"));
436        assert!(outcome.model_content.contains("Tasks 0/3"));
437    }
438
439    #[tokio::test]
440    async fn update_batches_and_appends_notes() {
441        let (ctx, broker, _rx) = ctx_with_broker();
442        let outcome = TaskCreateTool.execute(create_args(3), ctx).await;
443        assert!(outcome.error.is_none());
444
445        let (ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
446        let mut ctx2 = ctx2;
447        ctx2.tasks = Some(broker.clone());
448        let outcome = TaskUpdateTool
449            .execute(
450                serde_json::json!({ "updates": [
451                    { "id": 1, "status": "completed" },
452                    { "id": 2, "status": "in_progress" },
453                    { "id": 3, "status": "in_progress" },
454                ]}),
455                ctx2,
456            )
457            .await;
458        assert!(outcome.error.is_none());
459        assert!(outcome.model_content.contains("#1 -> completed"));
460        assert!(outcome.model_content.contains("Tasks 1/3"));
461        // Two in_progress after the batch: the advisory note must land.
462        assert!(
463            outcome
464                .model_content
465                .contains("at most one task in_progress")
466        );
467        assert_eq!(outcome.summary, "Tasks 1/3");
468    }
469
470    #[tokio::test]
471    async fn advisory_notes_follow_the_guidance_switch() {
472        let (mut ctx, broker, _rx) = ctx_with_broker();
473        ctx.model_id = "anthropic/some-model".to_string();
474        let outcome = TaskCreateTool.execute(create_args(2), ctx).await;
475        assert!(outcome.error.is_none());
476
477        let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
478        ctx2.tasks = Some(broker);
479        ctx2.model_id = "anthropic/some-model".to_string();
480        let outcome = TaskUpdateTool
481            .execute(
482                serde_json::json!({ "updates": [{ "id": 2, "status": "in_progress" }]}),
483                ctx2,
484            )
485            .await;
486        assert!(outcome.error.is_none());
487        assert!(
488            !outcome.model_content.contains("Note:"),
489            "a hosted API gets no coaching notes: {}",
490            outcome.model_content
491        );
492    }
493
494    #[tokio::test]
495    async fn update_all_unknown_ids_is_an_error() {
496        let (ctx, _broker, _rx) = ctx_with_broker();
497        let outcome = TaskUpdateTool
498            .execute(
499                serde_json::json!({ "updates": [{ "id": 42, "status": "completed" }]}),
500                ctx,
501            )
502            .await;
503        assert!(outcome.error.is_some());
504        assert!(
505            outcome
506                .error
507                .as_deref()
508                .unwrap_or_default()
509                .contains("no such task")
510        );
511    }
512
513    #[tokio::test]
514    async fn list_renders_descriptions_and_evidence() {
515        let (ctx, broker, _rx) = ctx_with_broker();
516        let outcome = TaskCreateTool
517            .execute(
518                serde_json::json!({ "tasks": [{
519                    "subject": "wire broker",
520                    "active_form": "wiring broker",
521                    "description": "through ExecContext",
522                    "status": "in_progress",
523                }]}),
524                ctx,
525            )
526            .await;
527        assert!(outcome.error.is_none());
528        broker
529            .record_evidence(mermaid_domain::EvidenceEntry {
530                tool: "edit_file".into(),
531                target: "src/x.rs".into(),
532                status: "ok".into(),
533            })
534            .await;
535
536        let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
537        ctx2.tasks = Some(broker);
538        let outcome = TaskListTool.execute(serde_json::json!({}), ctx2).await;
539        assert!(
540            outcome
541                .model_content
542                .contains("#1 [in_progress] wire broker")
543        );
544        assert!(outcome.model_content.contains("    through ExecContext"));
545        assert!(
546            outcome
547                .model_content
548                .contains("evidence: edit_file src/x.rs (ok)")
549        );
550    }
551
552    #[tokio::test]
553    async fn missing_broker_degrades_gracefully() {
554        let (ctx, _p) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
555        let outcome = TaskCreateTool.execute(create_args(1), ctx).await;
556        assert!(outcome.error.is_none());
557        assert!(outcome.model_content.contains("unavailable"));
558    }
559
560    #[tokio::test]
561    async fn create_rejects_malformed_args() {
562        let (ctx, _broker, _rx) = ctx_with_broker();
563        let outcome = TaskCreateTool
564            .execute(serde_json::json!({ "tasks": [] }), ctx)
565            .await;
566        assert!(outcome.error.is_some());
567
568        let (mut ctx2, _p) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
569        let (tx, _rx2) = tokio::sync::mpsc::channel(8);
570        ctx2.tasks = Some(TaskBroker::new(tx));
571        let outcome = TaskCreateTool
572            .execute(serde_json::json!({ "tasks": [{ "subject": "x" }] }), ctx2)
573            .await;
574        assert!(
575            outcome
576                .error
577                .as_deref()
578                .unwrap_or_default()
579                .contains("active_form")
580        );
581    }
582}