Skip to main content

work_buddy/tools/
tasks.rs

1//! Task list management
2
3use anyhow::{Result, anyhow};
4use chrono::Local;
5use chrono::format::SecondsFormat;
6use helpers::TruncatedDisplay;
7use llimorse::{Agent, CallableTool};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt::{self, Write as _};
12use std::fs;
13use std::io::{self, Write as _};
14use std::path::PathBuf;
15use std::sync::Arc;
16use tokio::sync::Mutex;
17
18/// Task file
19#[derive(Debug)]
20pub struct TaskFile {
21    /// Path where to load/store the content
22    path: PathBuf,
23
24    /// Content of the list
25    content: HashMap<String, Task>,
26}
27
28impl TaskFile {
29    /// Open the task file under the given `path`.
30    pub fn open(path: PathBuf) -> Result<Self> {
31        let content = match fs::read_to_string(&path) {
32            Ok(content) => content,
33            Err(err) if err.kind() == io::ErrorKind::NotFound => {
34                let mut file = fs::File::create_new(&path).map_err(|c_err| {
35                    anyhow!("Opening file failed: {err}; and creating failed, too: {c_err}")
36                })?;
37
38                let content = String::from("{}");
39                file.write_all(content.as_bytes())?;
40                content
41            }
42            Err(err) => return Err(err.into()),
43        };
44
45        let content = serde_json::from_str(&content)?;
46
47        Ok(TaskFile { path, content })
48    }
49
50    /// Build the list of active tasks as a single message
51    ///
52    /// This also includes critical backlog items.
53    pub fn active_tasks_message(&self) -> String {
54        let active_tasks = self.content.iter().filter(|(_, task)| {
55            task.settable.status != TaskStatus::Backlog
56                || task.settable.priority == TaskPriority::Critical
57        });
58
59        let mut message = None::<String>;
60        for (id, task) in active_tasks {
61            let message = message.get_or_insert_with(|| "List of active tasks:".into());
62
63            write!(
64                message,
65                "\n- '{id}' ({:?}, {:?} priority):  - Components: {}\n",
66                task.settable.status,
67                task.settable.priority,
68                task.settable
69                    .components
70                    .iter()
71                    .map(|s| s as &str)
72                    .collect::<Vec<_>>()
73                    .join(", "),
74            )
75            .expect("Failed to append to task list string");
76
77            for (list, header, inline_name) in [
78                (&task.settable.description, "Description", "description"),
79                (&task.settable.tickets, "Tickets", "public tickets"),
80            ] {
81                if list.is_empty() {
82                    writeln!(message, "  - (does not have any {inline_name} yet)")
83                        .expect("Failed to append to task list string");
84                } else {
85                    writeln!(message, "  - {header}:")
86                        .expect("Failed to append to task list string");
87                    for (key, value) in list {
88                        writeln!(message, "    - {key}: {value}")
89                            .expect("Failed to append to task list string");
90                    }
91                }
92            }
93
94            write!(
95                message,
96                "  - Created: {}\n  - Last updated: {}",
97                task.created_at, task.updated_at,
98            )
99            .expect("Failed to append to task list string");
100        }
101
102        message.unwrap_or_else(|| "(There are no active tasks.)".into())
103    }
104
105    /// Add relevant tools for this file to `agent`.
106    pub fn add_tools(self, agent: &mut Agent) {
107        let this = Arc::new(Mutex::new(self));
108
109        agent.add_tool(TaskAdd::new(Arc::clone(&this)));
110        agent.add_tool(TaskRemove::new(Arc::clone(&this)));
111        agent.add_tool(TaskUpdate::new(Arc::clone(&this)));
112        agent.add_tool(TaskQuery::new(this));
113    }
114
115    /// Write the contents into the file.
116    fn write(&self) -> Result<()> {
117        let json = serde_json::to_string(&self.content)
118            .map_err(|err| anyhow!("Failed to convert task list to JSON: {err}"))?;
119
120        fs::write(&self.path, json)
121            .map_err(|err| anyhow!("Failed to write task list file: {err}"))?;
122
123        Ok(())
124    }
125}
126
127/// A task (ID is in the `HashMap`)
128#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
129struct Task {
130    /// LLM-settable fields
131    #[serde(flatten)]
132    settable: TaskSettable,
133
134    /// When the task was first created
135    created_at: String,
136
137    /// When the task was last updated
138    updated_at: String,
139}
140
141/// The part of a task that is LLM-settable
142#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
143struct TaskSettable {
144    /// Task status
145    status: TaskStatus,
146
147    /// Task priority
148    #[serde(default)]
149    priority: TaskPriority,
150
151    /// What components were touched, i.e. which projects were affected
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    components: Vec<String>,
154
155    /// Ticket URLs
156    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
157    tickets: HashMap<String, String>,
158
159    /// What there is to do, keyed by keywords (to allow information to be added over time)
160    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
161    description: HashMap<String, String>,
162}
163
164/// The task’s status
165#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, JsonSchema)]
166#[schemars(inline)]
167enum TaskStatus {
168    /// Not currently active, but planned for later, at some point
169    Backlog,
170
171    /// Completely new on the list, needs triaging first
172    NotYetTriaged,
173
174    /// Currently being worked on
175    InProgress,
176
177    /// Used to be worked on, but currently blocked by something (reason goes into the description)
178    Blocked,
179}
180
181/// The task’s priority
182#[derive(
183    Clone, Copy, Debug, Default, Eq, PartialEq, PartialOrd, Deserialize, Serialize, JsonSchema,
184)]
185#[schemars(inline)]
186enum TaskPriority {
187    /// Somewhere in the background, if there is time
188    Low,
189
190    /// Normal task priority
191    #[default]
192    Normal,
193
194    /// Elevated priority, is needed soon
195    High,
196
197    /// Absolutely critical priority, trounces everything else
198    Critical,
199}
200
201impl fmt::Display for Task {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(
204            f,
205            "{} created_at={} updated_at={}",
206            self.settable, self.created_at, self.updated_at
207        )
208    }
209}
210
211impl fmt::Display for TaskSettable {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        write!(
214            f,
215            "status={:?} prio={:?} components={:?}",
216            self.status, self.priority, self.components
217        )?;
218
219        for (map, title) in [(&self.tickets, "tickets"), (&self.description, "desc")] {
220            write!(f, " {title}=[")?;
221            let len = map.len();
222            for (i, (key, value)) in map.iter().enumerate() {
223                let separator = if i == len - 1 { "" } else { ", " };
224                write!(f, "{key}={:?}{separator}", value.truncated_display(100))?;
225            }
226            write!(f, "]")?;
227        }
228
229        Ok(())
230    }
231}
232
233llimorse::tool! {
234    'name: "task_add";
235
236    /// Add a task to the task list.
237    #[derive(Debug)]
238    'params: pub struct TaskAddParams {
239        /// Meaningful ID to distinguish from other tasks
240        id: String,
241
242        /// Task to add
243        #[serde(flatten)]
244        task: TaskSettable,
245    }
246
247    /// Result of adding a new task.
248    #[derive(Debug)]
249    'result: pub struct TaskAddResult {
250        /// ID of the new task
251        id: String,
252    }
253
254    /// Add a task to the task list.
255    #[derive(Debug)]
256    'state: pub struct TaskAdd {
257        /// The central task file
258        file: Arc<Mutex<TaskFile>>,
259    }
260}
261
262impl fmt::Display for TaskAddParams {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        write!(f, "id={} {}", self.id, self.task)
265    }
266}
267
268impl fmt::Display for TaskAddResult {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        write!(f, "id={}", self.id)
271    }
272}
273
274impl TaskAdd {
275    /// Create a task_add tool for the given file.
276    pub fn new(file: Arc<Mutex<TaskFile>>) -> Self {
277        TaskAdd { file }
278    }
279}
280
281impl CallableTool for TaskAdd {
282    async fn execute(&self, _agent: &Agent, params: TaskAddParams) -> Result<TaskAddResult> {
283        let mut file = self.file.lock().await;
284
285        if file.content.contains_key(&params.id) {
286            return Err(anyhow!(
287                "Task with ID {} already exists in the task list",
288                params.id
289            ));
290        }
291
292        let now = Local::now().to_rfc3339_opts(SecondsFormat::Secs, false);
293        let task = Task {
294            settable: params.task.clone(),
295            created_at: now.clone(),
296            updated_at: now,
297        };
298        file.content.insert(params.id.clone(), task);
299
300        file.write()?;
301
302        Ok(TaskAddResult { id: params.id })
303    }
304}
305
306llimorse::tool! {
307    'name: "task_remove";
308
309    /// Remove a task from the task list.
310    #[derive(Debug)]
311    'params: pub struct TaskRemoveParams {
312        /// ID of the task to remove
313        id: String,
314    }
315
316    /// Result of removing a task.
317    #[derive(Debug)]
318    'result: pub struct TaskRemoveResult {
319        /// ID of the removed task
320        id: String,
321    }
322
323    /// Remove a task from the task list.
324    #[derive(Debug)]
325    'state: pub struct TaskRemove {
326        /// The central task file
327        file: Arc<Mutex<TaskFile>>,
328    }
329}
330
331impl fmt::Display for TaskRemoveParams {
332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333        write!(f, "id={}", self.id)
334    }
335}
336
337impl fmt::Display for TaskRemoveResult {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        write!(f, "id={}", self.id)
340    }
341}
342
343impl TaskRemove {
344    /// Create a task_remove tool for the given file.
345    pub fn new(file: Arc<Mutex<TaskFile>>) -> Self {
346        TaskRemove { file }
347    }
348}
349
350impl CallableTool for TaskRemove {
351    async fn execute(&self, _agent: &Agent, params: TaskRemoveParams) -> Result<TaskRemoveResult> {
352        let mut file = self.file.lock().await;
353
354        file.content
355            .remove(&params.id)
356            .ok_or_else(|| anyhow!("Task with ID {} does not exist in the task list", params.id))?;
357
358        file.write()?;
359
360        Ok(TaskRemoveResult { id: params.id })
361    }
362}
363
364llimorse::tool! {
365    'name: "task_update";
366
367    /// Update/edit an existing task on the task list.
368    #[derive(Debug)]
369    'params: pub struct TaskUpdateParams {
370        /// ID of the existing task on the list
371        id: String,
372
373        /// New task status; default is no change
374        #[serde(default, skip_serializing_if = "Option::is_none")]
375        status: Option<TaskStatus>,
376
377        /// New task components list, fully replacing the existing one
378        #[serde(default, skip_serializing_if = "Vec::is_empty")]
379        components: Vec<String>,
380
381        /// New task priority; default is no change
382        #[serde(default, skip_serializing_if = "Option::is_none")]
383        priority: Option<TaskPriority>,
384
385        /// Tickets to add to the task (or specify nil to remove a ticket)
386        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
387        tickets: HashMap<String, Option<String>>,
388
389        /// Information to add to the task (or specify nil to remove a keyword). Fields not
390        /// specfied will remain unchanged.
391        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
392        description: HashMap<String, Option<String>>,
393    }
394
395    /// Result of updating a task.
396    #[derive(Debug)]
397    'result: pub struct TaskUpdateResult {
398        /// ID of the task that has been updated
399        id: String,
400
401        /// New complete task state
402        #[serde(flatten)]
403        task: Task,
404    }
405
406    /// Update/edit an existing task on the task list.
407    #[derive(Debug)]
408    'state: pub struct TaskUpdate {
409        /// The central task file
410        file: Arc<Mutex<TaskFile>>,
411    }
412}
413
414impl fmt::Display for TaskUpdateParams {
415    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
416        write!(f, "id={}", self.id)?;
417
418        if let Some(status) = self.status {
419            write!(f, " status={status:?}")?;
420        }
421        if let Some(priority) = self.priority {
422            write!(f, " prio={priority:?}")?;
423        }
424        write!(f, " components={:?}", self.components)?;
425        for (map, title) in [(&self.tickets, "tickets"), (&self.description, "desc")] {
426            let len = map.len();
427            if len > 0 {
428                write!(f, " {title}=[")?;
429                for (i, (key, value)) in map.iter().enumerate() {
430                    let separator = if i == len - 1 { "" } else { ", " };
431                    if let Some(value) = value {
432                        write!(f, "{key}={:?}{separator}", value.truncated_display(100))?;
433                    } else {
434                        write!(f, "{key}=nil{separator}")?;
435                    }
436                }
437                write!(f, "]")?;
438            }
439        }
440
441        Ok(())
442    }
443}
444
445impl fmt::Display for TaskUpdateResult {
446    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447        write!(f, "{}={}", self.id, self.task)
448    }
449}
450
451impl TaskUpdate {
452    /// Create a task_update tool for the given file.
453    pub fn new(file: Arc<Mutex<TaskFile>>) -> Self {
454        TaskUpdate { file }
455    }
456}
457
458impl CallableTool for TaskUpdate {
459    async fn execute(&self, _agent: &Agent, params: TaskUpdateParams) -> Result<TaskUpdateResult> {
460        let mut file = self.file.lock().await;
461
462        let task = file
463            .content
464            .get_mut(&params.id)
465            .ok_or_else(|| anyhow!("Task with ID {} does not exist in the task list", params.id))?;
466
467        if let Some(status) = params.status {
468            task.settable.status = status;
469        }
470        if let Some(priority) = params.priority {
471            task.settable.priority = priority;
472        }
473        task.settable.components = params.components;
474
475        for (state, amendment) in [
476            (&mut task.settable.tickets, params.tickets),
477            (&mut task.settable.description, params.description),
478        ] {
479            for (key, value) in amendment {
480                if let Some(value) = value {
481                    state.insert(key, value);
482                } else {
483                    state.remove(&key);
484                }
485            }
486        }
487
488        task.updated_at = Local::now().to_rfc3339_opts(SecondsFormat::Secs, false);
489        let task = task.clone();
490
491        file.write()?;
492
493        Ok(TaskUpdateResult {
494            id: params.id,
495            task,
496        })
497    }
498}
499
500llimorse::tool! {
501    'name: "task_query";
502
503    /// Query tasks from the list of *all* tasks.
504    #[derive(Debug)]
505    'params: pub struct TaskQueryParams {
506        /// Query specific task IDs
507        #[serde(default, skip_serializing_if = "Option::is_none")]
508        ids: Option<Vec<String>>,
509
510        /// List only tasks with one of these statuses
511        #[serde(default, skip_serializing_if = "Option::is_none")]
512        status: Option<Vec<TaskStatus>>,
513
514        /// List only tasks with at least this priority
515        #[serde(default, skip_serializing_if = "Option::is_none")]
516        min_priority: Option<TaskPriority>,
517
518        /// List only tasks with any of these components
519        #[serde(default, skip_serializing_if = "Option::is_none")]
520        components: Option<Vec<String>>,
521    }
522
523    /// Tasks on the task list, as requested.
524    #[derive(Debug)]
525    'result: pub struct TaskQueryResult {
526        /// Matching tasks from the task list, keyed by ID
527        #[serde(flatten)]
528        list: HashMap<String, Task>,
529    }
530
531    /// Query tasks from the list of *all* tasks.
532    #[derive(Debug)]
533    'state: pub struct TaskQuery {
534        /// The central task file
535        file: Arc<Mutex<TaskFile>>,
536    }
537}
538
539impl fmt::Display for TaskQueryParams {
540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541        let mut display = Vec::with_capacity(3);
542
543        if let Some(ids) = &self.ids {
544            display.push(format!("id in {ids:?}"));
545        }
546
547        if let Some(status) = &self.status {
548            display.push(format!("status in {status:?}"));
549        }
550
551        if let Some(min_priority) = &self.min_priority {
552            display.push(format!("priority >= {min_priority:?}"));
553        }
554
555        if let Some(components) = &self.components {
556            display.push(format!("component in {components:?}"));
557        }
558
559        write!(f, "{}", display.join("; "))
560    }
561}
562
563impl fmt::Display for TaskQueryResult {
564    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565        let count = self.list.len();
566        for (i, (id, task)) in self.list.iter().enumerate() {
567            if i == count - 1 {
568                write!(f, "{id}={{{task}}}")?;
569            } else {
570                write!(f, "{id}={{{task}}}; ")?;
571            }
572        }
573
574        Ok(())
575    }
576}
577
578impl TaskQuery {
579    /// Create a task_query tool for the given file.
580    pub fn new(file: Arc<Mutex<TaskFile>>) -> Self {
581        TaskQuery { file }
582    }
583}
584
585impl CallableTool for TaskQuery {
586    async fn execute(&self, _agent: &Agent, params: TaskQueryParams) -> Result<TaskQueryResult> {
587        let file = self.file.lock().await;
588
589        let list = file
590            .content
591            .iter()
592            .filter(|(id, task)| {
593                if let Some(ids) = &params.ids
594                    && !ids.contains(id)
595                {
596                    return false;
597                }
598
599                if let Some(status) = &params.status
600                    && !status.contains(&task.settable.status)
601                {
602                    return false;
603                }
604
605                if let Some(min_priority) = &params.min_priority
606                    && task.settable.priority < *min_priority
607                {
608                    return false;
609                }
610
611                if let Some(components) = &params.components
612                    && !task
613                        .settable
614                        .components
615                        .iter()
616                        .any(|c| components.contains(c))
617                {
618                    return false;
619                }
620
621                true
622            })
623            .map(|(id, task)| (id.clone(), task.clone()))
624            .collect();
625
626        Ok(TaskQueryResult { list })
627    }
628}