1use 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#[derive(Debug)]
20pub struct TaskFile {
21 path: PathBuf,
23
24 content: HashMap<String, Task>,
26}
27
28impl TaskFile {
29 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 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 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 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#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
129struct Task {
130 #[serde(flatten)]
132 settable: TaskSettable,
133
134 created_at: String,
136
137 updated_at: String,
139}
140
141#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
143struct TaskSettable {
144 status: TaskStatus,
146
147 #[serde(default)]
149 priority: TaskPriority,
150
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 components: Vec<String>,
154
155 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
157 tickets: HashMap<String, String>,
158
159 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
161 description: HashMap<String, String>,
162}
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, JsonSchema)]
166#[schemars(inline)]
167enum TaskStatus {
168 Backlog,
170
171 NotYetTriaged,
173
174 InProgress,
176
177 Blocked,
179}
180
181#[derive(
183 Clone, Copy, Debug, Default, Eq, PartialEq, PartialOrd, Deserialize, Serialize, JsonSchema,
184)]
185#[schemars(inline)]
186enum TaskPriority {
187 Low,
189
190 #[default]
192 Normal,
193
194 High,
196
197 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 #[derive(Debug)]
238 'params: pub struct TaskAddParams {
239 id: String,
241
242 #[serde(flatten)]
244 task: TaskSettable,
245 }
246
247 #[derive(Debug)]
249 'result: pub struct TaskAddResult {
250 id: String,
252 }
253
254 #[derive(Debug)]
256 'state: pub struct TaskAdd {
257 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 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(¶ms.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 #[derive(Debug)]
311 'params: pub struct TaskRemoveParams {
312 id: String,
314 }
315
316 #[derive(Debug)]
318 'result: pub struct TaskRemoveResult {
319 id: String,
321 }
322
323 #[derive(Debug)]
325 'state: pub struct TaskRemove {
326 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 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(¶ms.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 #[derive(Debug)]
369 'params: pub struct TaskUpdateParams {
370 id: String,
372
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 status: Option<TaskStatus>,
376
377 #[serde(default, skip_serializing_if = "Vec::is_empty")]
379 components: Vec<String>,
380
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 priority: Option<TaskPriority>,
384
385 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
387 tickets: HashMap<String, Option<String>>,
388
389 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
392 description: HashMap<String, Option<String>>,
393 }
394
395 #[derive(Debug)]
397 'result: pub struct TaskUpdateResult {
398 id: String,
400
401 #[serde(flatten)]
403 task: Task,
404 }
405
406 #[derive(Debug)]
408 'state: pub struct TaskUpdate {
409 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 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(¶ms.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 #[derive(Debug)]
505 'params: pub struct TaskQueryParams {
506 #[serde(default, skip_serializing_if = "Option::is_none")]
508 ids: Option<Vec<String>>,
509
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 status: Option<Vec<TaskStatus>>,
513
514 #[serde(default, skip_serializing_if = "Option::is_none")]
516 min_priority: Option<TaskPriority>,
517
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 components: Option<Vec<String>>,
521 }
522
523 #[derive(Debug)]
525 'result: pub struct TaskQueryResult {
526 #[serde(flatten)]
528 list: HashMap<String, Task>,
529 }
530
531 #[derive(Debug)]
533 'state: pub struct TaskQuery {
534 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 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) = ¶ms.ids
594 && !ids.contains(id)
595 {
596 return false;
597 }
598
599 if let Some(status) = ¶ms.status
600 && !status.contains(&task.settable.status)
601 {
602 return false;
603 }
604
605 if let Some(min_priority) = ¶ms.min_priority
606 && task.settable.priority < *min_priority
607 {
608 return false;
609 }
610
611 if let Some(components) = ¶ms.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}