| 1 |
1 |
|
//! Task tools: the read surface (`list_tasks`, `get_task`) and the write
|
| 2 |
|
- |
//! surface (`create_task`, `bulk_import_tasks`, `update_task`, `complete_task`).
|
|
2 |
+ |
//! surface (`create_task`, `bulk_import_tasks`, `update_task`, `complete_task`,
|
|
3 |
+ |
//! plus the subtask writes `add_subtask`, `set_subtask_completed`,
|
|
4 |
+ |
//! `update_subtask`, `delete_subtask`).
|
| 3 |
5 |
|
//!
|
| 4 |
6 |
|
//! `bulk_import_tasks` is the `/dellm` migration primitive: it takes a parsed
|
| 5 |
7 |
|
//! backlog and creates tasks in one capability-gated call, deduping on a
|
| 21 |
23 |
|
use crate::caps;
|
| 22 |
24 |
|
use crate::context::Ctx;
|
| 23 |
25 |
|
use crate::convert::{
|
| 24 |
|
- |
MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_tags, parse_task_id,
|
| 25 |
|
- |
req_str, source_tag, task_row, task_summary_row,
|
|
26 |
+ |
MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_subtask_id, parse_tags,
|
|
27 |
+ |
parse_task_id, req_str, source_tag, subtask_row, task_row, task_summary_row,
|
| 26 |
28 |
|
};
|
| 27 |
29 |
|
|
| 28 |
30 |
|
fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
|
| 38 |
40 |
|
|
| 39 |
41 |
|
#[async_trait]
|
| 40 |
42 |
|
impl Tool for ListTasks {
|
| 41 |
|
- |
fn name(&self) -> &str {
|
|
43 |
+ |
fn name(&self) -> &'static str {
|
| 42 |
44 |
|
"list_tasks"
|
| 43 |
45 |
|
}
|
| 44 |
|
- |
fn description(&self) -> &str {
|
|
46 |
+ |
fn description(&self) -> &'static str {
|
| 45 |
47 |
|
"List tasks as compact rows. Optional filters: `project` (name), `status` (Pending|Started|Completed), `tag`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_task` for the full text. The reply carries `total` (rows matching the filters) and, when more remain, `next_offset`."
|
| 46 |
48 |
|
}
|
| 47 |
49 |
|
fn kind(&self) -> ToolKind {
|
| 115 |
117 |
|
|
| 116 |
118 |
|
#[async_trait]
|
| 117 |
119 |
|
impl Tool for GetTask {
|
| 118 |
|
- |
fn name(&self) -> &str {
|
|
120 |
+ |
fn name(&self) -> &'static str {
|
| 119 |
121 |
|
"get_task"
|
| 120 |
122 |
|
}
|
| 121 |
|
- |
fn description(&self) -> &str {
|
| 122 |
|
- |
"Fetch one task by id, including its subtasks and annotations."
|
|
123 |
+ |
fn description(&self) -> &'static str {
|
|
124 |
+ |
"Fetch one task by id, including its subtasks and annotations. Each subtask carries its `id`, which the subtask write tools take."
|
| 123 |
125 |
|
}
|
| 124 |
126 |
|
fn kind(&self) -> ToolKind {
|
| 125 |
127 |
|
ToolKind::Read
|
| 156 |
158 |
|
.map_err(|e| fail(self.name(), e))?;
|
| 157 |
159 |
|
|
| 158 |
160 |
|
let mut row = task_row(&task);
|
| 159 |
|
- |
row["subtasks"] = json!(
|
| 160 |
|
- |
subtasks
|
| 161 |
|
- |
.iter()
|
| 162 |
|
- |
.map(|s| json!({ "text": s.text, "completed": s.is_completed }))
|
| 163 |
|
- |
.collect::<Vec<_>>()
|
| 164 |
|
- |
);
|
|
161 |
+ |
row["subtasks"] = json!(subtasks.iter().map(subtask_row).collect::<Vec<_>>());
|
| 165 |
162 |
|
row["annotations"] = json!(
|
| 166 |
163 |
|
annotations
|
| 167 |
164 |
|
.iter()
|
| 178 |
175 |
|
|
| 179 |
176 |
|
#[async_trait]
|
| 180 |
177 |
|
impl Tool for CreateTask {
|
| 181 |
|
- |
fn name(&self) -> &str {
|
|
178 |
+ |
fn name(&self) -> &'static str {
|
| 182 |
179 |
|
"create_task"
|
| 183 |
180 |
|
}
|
| 184 |
|
- |
fn description(&self) -> &str {
|
|
181 |
+ |
fn description(&self) -> &'static str {
|
| 185 |
182 |
|
"Create one task. `project` (name) is resolved to a project, creating it if absent. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low."
|
| 186 |
183 |
|
}
|
| 187 |
184 |
|
fn kind(&self) -> ToolKind {
|
| 240 |
237 |
|
|
| 241 |
238 |
|
#[async_trait]
|
| 242 |
239 |
|
impl Tool for BulkImportTasks {
|
| 243 |
|
- |
fn name(&self) -> &str {
|
|
240 |
+ |
fn name(&self) -> &'static str {
|
| 244 |
241 |
|
"bulk_import_tasks"
|
| 245 |
242 |
|
}
|
| 246 |
|
- |
fn description(&self) -> &str {
|
|
243 |
+ |
fn description(&self) -> &'static str {
|
| 247 |
244 |
|
"Create many tasks in one call (the /dellm migration primitive). Each item: {description, project?, tags?, due?, priority?, source?}. `source` is a provenance key (e.g. file:line); an item whose source tag already exists is skipped, so re-running is idempotent. Returns created/skipped counts and the new task ids."
|
| 248 |
245 |
|
}
|
| 249 |
246 |
|
fn kind(&self) -> ToolKind {
|
| 364 |
361 |
|
|
| 365 |
362 |
|
#[async_trait]
|
| 366 |
363 |
|
impl Tool for UpdateTaskTool {
|
| 367 |
|
- |
fn name(&self) -> &str {
|
|
364 |
+ |
fn name(&self) -> &'static str {
|
| 368 |
365 |
|
"update_task"
|
| 369 |
366 |
|
}
|
| 370 |
|
- |
fn description(&self) -> &str {
|
|
367 |
+ |
fn description(&self) -> &'static str {
|
| 371 |
368 |
|
"Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project`, and `commit`. `commit` appends an advancing commit to the task's commit list, repo-qualified as `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); use `complete_task` to record the closing commit."
|
| 372 |
369 |
|
}
|
| 373 |
370 |
|
fn kind(&self) -> ToolKind {
|
| 478 |
475 |
|
|
| 479 |
476 |
|
#[async_trait]
|
| 480 |
477 |
|
impl Tool for CompleteTask {
|
| 481 |
|
- |
fn name(&self) -> &str {
|
|
478 |
+ |
fn name(&self) -> &'static str {
|
| 482 |
479 |
|
"complete_task"
|
| 483 |
480 |
|
}
|
| 484 |
|
- |
fn description(&self) -> &str {
|
|
481 |
+ |
fn description(&self) -> &'static str {
|
| 485 |
482 |
|
"Mark a task complete by id. Optional `commit` records the closing commit, repo-qualified as `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); it is appended to the task's commit list and flagged as the closer."
|
| 486 |
483 |
|
}
|
| 487 |
484 |
|
fn kind(&self) -> ToolKind {
|
| 543 |
540 |
|
}
|
| 544 |
541 |
|
}
|
| 545 |
542 |
|
|
|
543 |
+ |
// subtask writes
|
|
544 |
+ |
|
|
545 |
+ |
pub struct AddSubtask(pub Arc<Ctx>);
|
|
546 |
+ |
|
|
547 |
+ |
#[async_trait]
|
|
548 |
+ |
impl Tool for AddSubtask {
|
|
549 |
+ |
fn name(&self) -> &'static str {
|
|
550 |
+ |
"add_subtask"
|
|
551 |
+ |
}
|
|
552 |
+ |
fn description(&self) -> &'static str {
|
|
553 |
+ |
"Add a subtask to a task. Pass `text` for a plain checklist item, or `linked_task_id` to link an existing task as a subtask (its completion then tracks that task's status) — exactly one of the two. Subtasks append in call order; read them back with `get_task`."
|
|
554 |
+ |
}
|
|
555 |
+ |
fn kind(&self) -> ToolKind {
|
|
556 |
+ |
ToolKind::Write(caps::task_subtask_add())
|
|
557 |
+ |
}
|
|
558 |
+ |
fn input_schema(&self) -> Value {
|
|
559 |
+ |
json!({
|
|
560 |
+ |
"type": "object",
|
|
561 |
+ |
"properties": {
|
|
562 |
+ |
"task_id": { "type": "string" },
|
|
563 |
+ |
"text": { "type": "string" },
|
|
564 |
+ |
"linked_task_id": { "type": "string" }
|
|
565 |
+ |
},
|
|
566 |
+ |
"required": ["task_id"]
|
|
567 |
+ |
})
|
|
568 |
+ |
}
|
|
569 |
+ |
async fn call(&self, args: Value) -> Result<ToolCallResult> {
|
|
570 |
+ |
let task_id = parse_task_id(self.name(), req_str(self.name(), &args, "task_id")?)?;
|
|
571 |
+ |
let text = args
|
|
572 |
+ |
.get("text")
|
|
573 |
+ |
.and_then(Value::as_str)
|
|
574 |
+ |
.map(str::trim)
|
|
575 |
+ |
.filter(|s| !s.is_empty());
|
|
576 |
+ |
let linked = args
|
|
577 |
+ |
.get("linked_task_id")
|
|
578 |
+ |
.and_then(Value::as_str)
|
|
579 |
+ |
.map(str::trim)
|
|
580 |
+ |
.filter(|s| !s.is_empty());
|
|
581 |
+ |
|
|
582 |
+ |
// Exactly one form. Both would silently drop one; neither is a no-op
|
|
583 |
+ |
// that would otherwise report success.
|
|
584 |
+ |
let repo = self.0.tasks();
|
|
585 |
+ |
let created = match (text, linked) {
|
|
586 |
+ |
(Some(_), Some(_)) => {
|
|
587 |
+ |
return Err(Error::InvalidArgs {
|
|
588 |
+ |
tool: self.name().to_string(),
|
|
589 |
+ |
message: "pass either `text` or `linked_task_id`, not both".into(),
|
|
590 |
+ |
});
|
|
591 |
+ |
}
|
|
592 |
+ |
(None, None) => {
|
|
593 |
+ |
return Err(Error::InvalidArgs {
|
|
594 |
+ |
tool: self.name().to_string(),
|
|
595 |
+ |
message: "missing `text` (or `linked_task_id`)".into(),
|
|
596 |
+ |
});
|
|
597 |
+ |
}
|
|
598 |
+ |
(Some(t), None) => repo
|
|
599 |
+ |
.add_subtask(task_id, self.0.user_id, t)
|
|
600 |
+ |
.await
|
|
601 |
+ |
.map_err(|e| fail(self.name(), e))?,
|
|
602 |
+ |
(None, Some(l)) => {
|
|
603 |
+ |
let linked_id = parse_task_id(self.name(), l)?;
|
|
604 |
+ |
repo.add_subtask_link(task_id, self.0.user_id, linked_id)
|
|
605 |
+ |
.await
|
|
606 |
+ |
.map_err(|e| fail(self.name(), e))?
|
|
607 |
+ |
}
|
|
608 |
+ |
};
|
|
609 |
+ |
|
|
610 |
+ |
let created = created.ok_or_else(|| Error::ToolFailed {
|
|
611 |
+ |
tool: self.name().to_string(),
|
|
612 |
+ |
message: format!("no task with id {task_id}"),
|
|
613 |
+ |
})?;
|
|
614 |
+ |
Ok(ToolCallResult::text(
|
|
615 |
+ |
serde_json::to_string(&subtask_row(&created)).unwrap(),
|
|
616 |
+ |
))
|
|
617 |
+ |
}
|
|
618 |
+ |
}
|
|
619 |
+ |
|
|
620 |
+ |
pub struct SetSubtaskCompleted(pub Arc<Ctx>);
|
|
621 |
+ |
|
|
622 |
+ |
#[async_trait]
|
|
623 |
+ |
impl Tool for SetSubtaskCompleted {
|
|
624 |
+ |
fn name(&self) -> &'static str {
|
|
625 |
+ |
"set_subtask_completed"
|
|
626 |
+ |
}
|
|
627 |
+ |
fn description(&self) -> &'static str {
|
|
628 |
+ |
"Check or uncheck a subtask: pass `completed` true or false. Idempotent — setting a subtask to the state it already holds is a no-op, so a retry cannot flip it back. `task_id` is the parent task, `subtask_id` comes from `get_task`."
|
|
629 |
+ |
}
|
|
630 |
+ |
fn kind(&self) -> ToolKind {
|
|
631 |
+ |
ToolKind::Write(caps::task_subtask_update())
|
|
632 |
+ |
}
|
|
633 |
+ |
fn input_schema(&self) -> Value {
|
|
634 |
+ |
json!({
|
|
635 |
+ |
"type": "object",
|
|
636 |
+ |
"properties": {
|
|
637 |
+ |
"task_id": { "type": "string" },
|
|
638 |
+ |
"subtask_id": { "type": "string" },
|
|
639 |
+ |
"completed": { "type": "boolean" }
|
|
640 |
+ |
},
|
|
641 |
+ |
"required": ["task_id", "subtask_id", "completed"]
|
|
642 |
+ |
})
|
|
643 |
+ |
}
|
|
644 |
+ |
async fn call(&self, args: Value) -> Result<ToolCallResult> {
|
|
645 |
+ |
let task_id = parse_task_id(self.name(), req_str(self.name(), &args, "task_id")?)?;
|
|
646 |
+ |
let subtask_id = parse_subtask_id(self.name(), req_str(self.name(), &args, "subtask_id")?)?;
|
|
647 |
+ |
let want = args
|
|
648 |
+ |
.get("completed")
|
|
649 |
+ |
.and_then(Value::as_bool)
|
|
650 |
+ |
.ok_or_else(|| Error::InvalidArgs {
|
|
651 |
+ |
tool: self.name().to_string(),
|
|
652 |
+ |
message: "missing boolean field `completed`".into(),
|
|
653 |
+ |
})?;
|
|
654 |
+ |
|
|
655 |
+ |
// The repository exposes a toggle, which is the wrong shape for a retryable
|
|
656 |
+ |
// tool call: a repeat would undo the first. Read the current state and act
|
|
657 |
+ |
// only on a mismatch, so the call is idempotent in the requested state.
|
|
658 |
+ |
let repo = self.0.tasks();
|
|
659 |
+ |
let current = repo
|
|
660 |
+ |
.get_subtasks_for_task(task_id)
|
|
661 |
+ |
.await
|
|
662 |
+ |
.map_err(|e| fail(self.name(), e))?
|
|
663 |
+ |
.into_iter()
|
|
664 |
+ |
.find(|s| s.id == subtask_id)
|
|
665 |
+ |
.ok_or_else(|| Error::ToolFailed {
|
|
666 |
+ |
tool: self.name().to_string(),
|
|
667 |
+ |
message: format!("task {task_id} has no subtask {subtask_id}"),
|
|
668 |
+ |
})?;
|
|
669 |
+ |
|
|
670 |
+ |
if current.is_completed == want {
|
|
671 |
+ |
return Ok(ToolCallResult::text(
|
|
672 |
+ |
serde_json::to_string(&subtask_row(¤t)).unwrap(),
|
|
673 |
+ |
));
|
|
674 |
+ |
}
|
|
675 |
+ |
|
|
676 |
+ |
let updated = repo
|
|
677 |
+ |
.toggle_subtask(subtask_id, self.0.user_id)
|
|
678 |
+ |
.await
|
|
679 |
+ |
.map_err(|e| fail(self.name(), e))?
|
|
680 |
+ |
.ok_or_else(|| Error::ToolFailed {
|
|
681 |
+ |
tool: self.name().to_string(),
|
|
682 |
+ |
message: format!("subtask {subtask_id} vanished during update"),
|
|
683 |
+ |
})?;
|
|
684 |
+ |
Ok(ToolCallResult::text(
|
|
685 |
+ |
serde_json::to_string(&subtask_row(&updated)).unwrap(),
|
|
686 |
+ |
))
|
|
687 |
+ |
}
|
|
688 |
+ |
}
|
|
689 |
+ |
|
|
690 |
+ |
pub struct UpdateSubtaskTool(pub Arc<Ctx>);
|
|
691 |
+ |
|
|
692 |
+ |
#[async_trait]
|
|
693 |
+ |
impl Tool for UpdateSubtaskTool {
|
|
694 |
+ |
fn name(&self) -> &'static str {
|
|
695 |
+ |
"update_subtask"
|
|
696 |
+ |
}
|
|
697 |
+ |
fn description(&self) -> &'static str {
|
|
698 |
+ |
"Rewrite a subtask's text. Completion state is untouched; use `set_subtask_completed` for that."
|
|
699 |
+ |
}
|
|
700 |
+ |
fn kind(&self) -> ToolKind {
|
|
701 |
+ |
ToolKind::Write(caps::task_subtask_update())
|
|
702 |
+ |
}
|
|
703 |
+ |
fn input_schema(&self) -> Value {
|
|
704 |
+ |
json!({
|
|
705 |
+ |
"type": "object",
|
|
706 |
+ |
"properties": {
|
|
707 |
+ |
"subtask_id": { "type": "string" },
|
|
708 |
+ |
"text": { "type": "string" }
|
|
709 |
+ |
},
|
|
710 |
+ |
"required": ["subtask_id", "text"]
|
|
711 |
+ |
})
|
|
712 |
+ |
}
|
|
713 |
+ |
async fn call(&self, args: Value) -> Result<ToolCallResult> {
|
|
714 |
+ |
let subtask_id = parse_subtask_id(self.name(), req_str(self.name(), &args, "subtask_id")?)?;
|
|
715 |
+ |
let text = req_str(self.name(), &args, "text")?;
|
|
716 |
+ |
|
|
717 |
+ |
let updated = self
|
|
718 |
+ |
.0
|
|
719 |
+ |
.tasks()
|
|
720 |
+ |
.update_subtask(subtask_id, self.0.user_id, text)
|
|
721 |
+ |
.await
|
|
722 |
+ |
.map_err(|e| fail(self.name(), e))?
|
|
723 |
+ |
.ok_or_else(|| Error::ToolFailed {
|
|
724 |
+ |
tool: self.name().to_string(),
|
|
725 |
+ |
message: format!("no subtask with id {subtask_id}"),
|
|
726 |
+ |
})?;
|
|
727 |
+ |
Ok(ToolCallResult::text(
|
|
728 |
+ |
serde_json::to_string(&subtask_row(&updated)).unwrap(),
|
|
729 |
+ |
))
|
|
730 |
+ |
}
|
|
731 |
+ |
}
|
|
732 |
+ |
|
|
733 |
+ |
pub struct DeleteSubtask(pub Arc<Ctx>);
|
|
734 |
+ |
|
|
735 |
+ |
#[async_trait]
|
|
736 |
+ |
impl Tool for DeleteSubtask {
|
|
737 |
+ |
fn name(&self) -> &'static str {
|
|
738 |
+ |
"delete_subtask"
|
|
739 |
+ |
}
|
|
740 |
+ |
fn description(&self) -> &'static str {
|
|
741 |
+ |
"Delete a subtask outright. Prefer `set_subtask_completed` for work that is done — deleting discards the record that it was ever tracked."
|
|
742 |
+ |
}
|
|
743 |
+ |
fn kind(&self) -> ToolKind {
|
|
744 |
+ |
ToolKind::Write(caps::task_subtask_delete())
|
|
745 |
+ |
}
|
|
746 |
+ |
fn input_schema(&self) -> Value {
|
|
747 |
+ |
json!({
|
|
748 |
+ |
"type": "object",
|
|
749 |
+ |
"properties": { "subtask_id": { "type": "string" } },
|
|
750 |
+ |
"required": ["subtask_id"]
|
|
751 |
+ |
})
|
|
752 |
+ |
}
|
|
753 |
+ |
async fn call(&self, args: Value) -> Result<ToolCallResult> {
|
|
754 |
+ |
let subtask_id = parse_subtask_id(self.name(), req_str(self.name(), &args, "subtask_id")?)?;
|
|
755 |
+ |
let deleted = self
|
|
756 |
+ |
.0
|
|
757 |
+ |
.tasks()
|
|
758 |
+ |
.delete_subtask(subtask_id, self.0.user_id)
|
|
759 |
+ |
.await
|
|
760 |
+ |
.map_err(|e| fail(self.name(), e))?;
|
|
761 |
+ |
if !deleted {
|
|
762 |
+ |
return Err(Error::ToolFailed {
|
|
763 |
+ |
tool: self.name().to_string(),
|
|
764 |
+ |
message: format!("no subtask with id {subtask_id}"),
|
|
765 |
+ |
});
|
|
766 |
+ |
}
|
|
767 |
+ |
Ok(ToolCallResult::text(
|
|
768 |
+ |
serde_json::to_string(
|
|
769 |
+ |
&json!({ "deleted": true, "subtask_id": subtask_id.to_string() }),
|
|
770 |
+ |
)
|
|
771 |
+ |
.unwrap(),
|
|
772 |
+ |
))
|
|
773 |
+ |
}
|
|
774 |
+ |
}
|
|
775 |
+ |
|
| 546 |
776 |
|
// helpers
|
| 547 |
777 |
|
|
| 548 |
778 |
|
/// Resolve a project by name, creating a default `SideProject` if absent.
|